From 392aed54c84cea5fd8e6f0e97f06c3a2523aaa58 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 17:06:23 +0200 Subject: [PATCH] Add rounded division, remainder division, step rounding and a float factory All additions; the 10,000 call comparison against master shows no difference for existing calls. - divide() takes an optional rounding mode as third argument. Without it the quotient is cut off as before. With it the rounding is decided on whole numbers from the exact remainder, so ties are detected exactly at any scale. - divideWithRemainder() returns the whole number quotient and the remainder, with the same sign rule as mod(). - roundToStep() rounds to a multiple of any positive step, such as 0.05. - createFromFloat() keeps all digits needed to reproduce the float. The constructor casts floats through the precision ini setting and drops digits beyond the 14th by default. The conversion does not depend on LC_NUMERIC. - zero() and one(). allocate() used the declared scale as its unit. A value created with a scale of 0 keeps its decimals in 1.x, so its shares did not sum up. It now uses the decimals actually stored. --- docs/guide/arithmetic.md | 26 +++- docs/guide/creating.md | 26 +++- docs/guide/rounding.md | 16 +++ docs/guide/scale.md | 3 +- docs/reference/api.md | 9 +- docs/reference/upgrading.md | 3 + src/Decimal.php | 179 +++++++++++++++++++++-- tests/DecimalTest.php | 275 ++++++++++++++++++++++++++++++++++++ 8 files changed, 520 insertions(+), 17 deletions(-) diff --git a/docs/guide/arithmetic.md b/docs/guide/arithmetic.md index d0be489..822c9ee 100644 --- a/docs/guide/arithmetic.md +++ b/docs/guide/arithmetic.md @@ -35,12 +35,34 @@ echo Decimal::create('10')->divide('3', 4); // 3.3333 echo Decimal::create('2')->divide('3', 2); // 0.66 ``` -The result is cut off at the scale, not rounded. Divide with one more digit and round when you need the nearest value: +The result is cut off at the scale, not rounded. Pass a rounding mode as third argument to round it: ```php -echo Decimal::create('2')->divide('3', 3)->round(2); // 0.67 +echo Decimal::create('2')->divide('3', 2, Decimal::ROUND_HALF_UP); // 0.67 +echo Decimal::create('1')->divide('8', 2, Decimal::ROUND_HALF_EVEN); // 0.12 ``` +The rounding is decided on the exact quotient. All [rounding modes](./rounding#rounding-modes) work here at the scale, including floor and ceil. + +> [!TIP] Added in 1.4 +> The rounding mode argument. Without it `divide()` cuts off as before. + +### Quotient and remainder + +`divideWithRemainder()` returns the whole number quotient and what is left over: + +```php +[$quotient, $remainder] = Decimal::create('17')->divideWithRemainder(5); +// 3 and 2 + +[$quotient, $remainder] = Decimal::create('10')->divideWithRemainder('0.3'); +// 33 and 0.1 +``` + +The quotient is cut off towards zero, so the remainder has the sign of the value, like `mod()`: `-7` by `2` gives `-3` and `-1`. Quotient times divisor plus remainder always equals the value. + +> [!TIP] Added in 1.4 + Dividing by zero throws a `DivisionByZeroError`. ## Modulo diff --git a/docs/guide/creating.md b/docs/guide/creating.md index a1d4ed9..6686525 100644 --- a/docs/guide/creating.md +++ b/docs/guide/creating.md @@ -46,9 +46,33 @@ Decimal::create(INF); // Invalid non finite value Localized strings such as `'1.018,90'` are rejected. Parse them with `NumberFormatter` or your framework's locale tools first. ::: warning Floats -A float is converted with PHP's own float to string rules, which round to the `precision` ini setting. That hides small errors but cannot restore digits the float never had. For money and other exact values, read the number as a string from the request or the database. +A float is converted with PHP's own float to string rules, which round to the `precision` ini setting (14 digits by default). That hides small errors but also drops real digits. For money and other exact values, read the number as a string from the request or the database. ::: +## Floats with all their digits + +When a float is all you have, `createFromFloat()` keeps every digit needed to get the same float back, independent of ini settings: + +```php +echo Decimal::create(1234567890.1234567); // 1234567890.1235 +echo Decimal::createFromFloat(1234567890.1234567); // 1234567890.1234567 + +echo Decimal::create(0.1 + 0.2); // 0.3 +echo Decimal::createFromFloat(0.1 + 0.2); // 0.30000000000000004 +``` + +The second pair shows the trade: the result is what the float really holds, including its error. + +## Zero and one + +```php +$total = Decimal::zero(); +$factor = Decimal::one(); +``` + +> [!TIP] Added in 1.4 +> `createFromFloat()`, `zero()` and `one()`. + ::: info Exponent limit Exponents beyond `Decimal::MAX_EXPONENT` (10000 in either direction) throw an `InvalidArgumentException`. Without a limit, the twelve characters of `'1e1000000000'` would expand to a string of a billion digits. A subclass can redefine the constant. ::: diff --git a/docs/guide/rounding.md b/docs/guide/rounding.md index 6e394b8..83f6617 100644 --- a/docs/guide/rounding.md +++ b/docs/guide/rounding.md @@ -101,6 +101,22 @@ echo Decimal::create('12.3')->truncate(5); // 12.3 `truncate()` never adds zeros. Use `round()` when the result needs a fixed scale. +## Rounding to a step + +`roundToStep()` rounds to the nearest multiple of any positive value, such as five cents for cash payments: + +```php +echo Decimal::create('1.03')->roundToStep('0.05'); // 1.05 +echo Decimal::create('1.02')->roundToStep('0.05'); // 1.00 +echo Decimal::create('7.4')->roundToStep('0.25'); // 7.50 + +echo Decimal::create('1.01')->roundToStep('0.05', Decimal::ROUND_UP); // 1.05 +``` + +The result has the scale of the step. A step of zero or below throws an `InvalidArgumentException`. + +> [!TIP] Added in 1.4 + ## Rounding to tens and hundreds A negative scale rounds left of the decimal point: diff --git a/docs/guide/scale.md b/docs/guide/scale.md index de9ec24..cb03b0b 100644 --- a/docs/guide/scale.md +++ b/docs/guide/scale.md @@ -17,10 +17,11 @@ The library is strict about it: a value keeps the scale it was created with, and | `add()`, `subtract()` | larger scale of both operands | `1.50 + 2.125 = 3.625` | | `multiply()` | sum of both scales | `1.55 * 2.00 = 3.1000` | | `divide()` | none, the argument is required | `10 / 3` at scale 4 is `3.3333` | +| `divideWithRemainder()` | 0 for the quotient, larger scale of both for the remainder | `10 / 0.3` is `33` and `0.1` | | `pow()`, `sqrt()`, `mod()` | scale of the value | `1.5 ** 2 = 2.2` | | `round()`, `truncate()` | the scale you pass | `12.375` rounded to 2 is `12.38` | -Every arithmetic method takes a scale argument to override the default. When the exact result has more digits than the scale allows, the rest is cut off without rounding. +Every arithmetic method takes a scale argument to override the default. When the exact result has more digits than the scale allows, the rest is cut off without rounding. `divide()` rounds when you pass a mode as third argument. ## Repeated multiplication diff --git a/docs/reference/api.md b/docs/reference/api.md index 75b05ff..e4677d8 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -11,6 +11,8 @@ Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and | `new Decimal($value, ?int $scale = null, bool $strict = false)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. | | `Decimal::create($value, ?int $scale = null, bool $strict = false): static` | Same as the constructor, for chaining. | | `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. | +| `Decimal::createFromFloat(float $value): static` | Keeps all digits of the float, independent of the `precision` ini setting. | +| `Decimal::zero(): static`, `Decimal::one(): static` | The values `0` and `1`. | See [Creating decimals](../guide/creating). @@ -21,7 +23,8 @@ See [Creating decimals](../guide/creating). | `add($value, ?int $scale = null): static` | Sum | larger of both | | `subtract($value, ?int $scale = null): static` | Difference | larger of both | | `multiply($value, ?int $scale = null): static` | Product | sum of both | -| `divide($value, int $scale): static` | Quotient, cut off at `$scale` | required | +| `divide($value, int $scale, ?int $roundMode = null): static` | Quotient, cut off at `$scale`, or rounded when a mode is given | required | +| `divideWithRemainder($value): array` | Whole number quotient and remainder | 0, and larger of both | | `mod($value, ?int $scale = null): static` | Remainder. `$value` is a `Decimal`, string or integer. | scale of the value | | `pow($exponent, ?int $scale = null): static` | Power. `$exponent` is a whole number as `Decimal`, string or integer. | scale of the value | | `sqrt(?int $scale = null): static` | Square root | scale of the value | @@ -49,6 +52,7 @@ See [Arithmetic](../guide/arithmetic). | --- | --- | | `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. A negative scale rounds to tens, hundreds and so on. | | `withScale(int $scale, int $roundMode = Decimal::ROUND_HALF_UP): static` | Pads or rounds to exactly `$scale` decimal places, in any mode. | +| `roundToStep($step, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to the nearest multiple of `$step`. | | `floor(): static` | Nearest integer towards negative infinity. | | `ceil(): static` | Nearest integer towards positive infinity. | | `truncate(int $scale = 0): static` | Cuts off digits beyond `$scale`. | @@ -120,7 +124,8 @@ See [Conversion](../guide/conversion). | Unknown mode in `withScale()` | `InvalidArgumentException` | | Empty list in `average()`, `min()`, `max()`; invalid ratios in `allocate()`; `clamp()` with `$min` above `$max` | `InvalidArgumentException` | | Strict mode and the input has more decimals than the scale | `InvalidArgumentException` | -| `divide()` or `mod()` by zero | `DivisionByZeroError` | +| `divide()`, `divideWithRemainder()` or `mod()` by zero | `DivisionByZeroError` | +| Unknown mode in `divide()` or `roundToStep()`; negative scale in `divide()` with a mode; `roundToStep()` with a step of zero or below | `InvalidArgumentException` | | `toInt()` or `toFloat()` on a value that does not fit | `TypeError` | | `sqrt()` of a negative value, `pow()` with a fractional exponent, negative scale in an arithmetic method | `ValueError` from bcmath | diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md index 76eadf3..634ab8c 100644 --- a/docs/reference/upgrading.md +++ b/docs/reference/upgrading.md @@ -8,6 +8,9 @@ PHP 8.2 is now required. Composer keeps PHP 8.1 projects on 1.3. - Rounding modes `ROUND_HALF_DOWN`, `ROUND_HALF_EVEN`, `ROUND_HALF_ODD`, `ROUND_UP` and `ROUND_DOWN`, see [Rounding](../guide/rounding#rounding-modes) - `withScale()` to pad or round to a fixed scale, and `round()` with a negative scale +- `divide()` takes a rounding mode as third argument, `divideWithRemainder()` returns quotient and remainder +- `roundToStep()` for multiples such as 0.05 +- `Decimal::createFromFloat()`, `zero()` and `one()` - `Decimal::sum()`, `average()`, `min()` and `max()` - `split()` and `allocate()` to divide an amount without a remainder getting lost - `shift()`, `between()`, `clamp()`, `isEven()`, `isOdd()` and `Decimal::tryCreate()` diff --git a/src/Decimal.php b/src/Decimal.php index 5ffd5b4..8e7db89 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -216,6 +216,51 @@ public static function tryCreate(object|string|float|int|null $value, ?int $scal } } + /** + * Creates a decimal from a float with all the digits needed to get the same float back. + * + * Passing a float to create() goes through PHP's string cast and its `precision` + * ini setting, which drops digits beyond the 14th by default. + * + * @param float $value + * + * @throws \InvalidArgumentException If the float is INF or NAN. + * + * @return static + */ + public static function createFromFloat(float $value): static + { + if (!is_finite($value)) { + throw new InvalidArgumentException('Invalid non finite value'); + } + + $string = '0'; + foreach ([15, 16, 17] as $digits) { + $string = sprintf('%.' . $digits . 'H', $value); + if ((float)$string === $value) { + break; + } + } + + return new static(str_replace('.0E', 'E', $string)); + } + + /** + * @return static + */ + public static function zero(): static + { + return new static('0'); + } + + /** + * @return static + */ + public static function one(): static + { + return new static('1'); + } + /** * @param iterable $values * @@ -613,19 +658,118 @@ public function multiply(self|string|float|int $value, ?int $scale = null): stat * * @param self|string|float|int $value * @param int $scale Decimal places in the result + * @param int|null $roundMode One of the ROUND_* constants. Omit to cut off after $scale places. * * @throws \DivisionByZeroError if $value is zero. + * @throws \InvalidArgumentException * * @return static */ - public function divide(self|string|float|int $value, int $scale): static + public function divide(self|string|float|int $value, int $scale, ?int $roundMode = null): static + { + $decimal = static::create($value); + if ($decimal->isZero()) { + throw new DivisionByZeroError('Cannot divide by zero. Only Chuck Norris can!'); + } + if ($roundMode === null) { + return new static(bcdiv((string)$this, (string)$decimal, $scale)); + } + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + + return new static($this->roundedQuotient($decimal, $scale, $roundMode), $scale); + } + + /** + * Whole number quotient and what is left over: quotient * $value + remainder equals this value. + * + * The quotient is cut off towards zero, so the remainder has the sign of this value, like mod(). + * + * @param self|string|float|int $value + * + * @throws \DivisionByZeroError if $value is zero. + * + * @return array{0: static, 1: static} Quotient and remainder + */ + public function divideWithRemainder(self|string|float|int $value): array { $decimal = static::create($value); if ($decimal->isZero()) { throw new DivisionByZeroError('Cannot divide by zero. Only Chuck Norris can!'); } - return new static(bcdiv((string)$this, (string)$decimal, $scale)); + $scale = max($this->fractionalLength(), $decimal->fractionalLength()); + $quotient = bcdiv((string)$this, (string)$decimal, 0); + $remainder = bcsub((string)$this, bcmul($quotient, (string)$decimal, $scale), $scale); + + return [new static($quotient), new static($remainder)]; + } + + /** + * Rounds to the nearest multiple of $step, for example 0.05 for cash amounts. + * + * @param self|string|float|int $step Greater than zero + * @param int $roundMode One of the ROUND_* constants + * + * @throws \InvalidArgumentException + * + * @return static + */ + public function roundToStep(self|string|float|int $step, int $roundMode = self::ROUND_HALF_UP): static + { + $step = static::create($step); + if (!$step->isPositive()) { + throw new InvalidArgumentException('Step must be greater than zero.'); + } + + $steps = $this->roundedQuotient($step, 0, $roundMode); + $scale = $step->fractionalLength(); + + return new static(bcmul($steps, (string)$step, $scale), $scale); + } + + /** + * @param self $divisor Not zero + * @param int $scale + * @param int $roundMode + * + * @throws \InvalidArgumentException + * + * @return string This value divided by $divisor, rounded to $scale decimal places. + */ + protected function roundedQuotient(self $divisor, int $scale, int $roundMode): string + { + if (!in_array($roundMode, static::roundModes(), true)) { + throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); + } + + // Work on whole numbers so the remainder tells exactly whether and how to round. + $places = max($this->fractionalLength(), $divisor->fractionalLength()); + $numerator = $this->shift($places)->integralPart . str_repeat('0', $scale); + $denominator = $divisor->shift($places)->integralPart; + + $quotient = bcdiv($numerator, $denominator, 0); + $remainder = bcsub($numerator, bcmul($quotient, $denominator, 0), 0); + + $exact = $remainder === '0'; + $half = $exact ? -1 : bccomp(bcmul($remainder, '2', 0), $denominator, 0); + $odd = (int)substr($quotient, -1) % 2 === 1; + $negative = $this->negative !== $divisor->negative; + + if (static::roundsAwayFromZero($roundMode, $negative, $exact, $half, $odd)) { + $quotient = bcadd($quotient, '1', 0); + } + + return (string)(new static(($negative ? '-' : '') . $quotient))->shift(-$scale); + } + + /** + * @return int Number of decimals actually stored, which a scale of 0 does not limit in 1.x. + */ + protected function fractionalLength(): int + { + return strlen($this->fractionalPart); } /** @@ -792,9 +936,27 @@ protected function roundToScale(int $scale, int $roundMode): string $half = $exact ? -1 : bccomp('0.' . $fractionalPart, '0.5', strlen($fractionalPart)); $odd = (int)substr($integralPart, -1) % 2 === 1; - $awayFromZero = match ($roundMode) { - static::ROUND_FLOOR => !$exact && $this->negative, - static::ROUND_CEIL => !$exact && !$this->negative, + if (static::roundsAwayFromZero($roundMode, $this->negative, $exact, $half, $odd)) { + $integralPart = bcadd($integralPart, '1', 0); + } + + return (string)(new static(($this->negative ? '-' : '') . $integralPart))->shift(-$scale); + } + + /** + * @param int $roundMode + * @param bool $negative + * @param bool $exact If there is nothing to round. + * @param int $half -1, 0 or 1 for a rest below, at or above the half way point + * @param bool $odd If the last kept digit is odd. + * + * @return bool If the last kept digit has to move one step away from zero. + */ + protected static function roundsAwayFromZero(int $roundMode, bool $negative, bool $exact, int $half, bool $odd): bool + { + return match ($roundMode) { + static::ROUND_FLOOR => !$exact && $negative, + static::ROUND_CEIL => !$exact && !$negative, static::ROUND_UP => !$exact, static::ROUND_DOWN => false, static::ROUND_HALF_DOWN => $half > 0, @@ -802,11 +964,6 @@ protected function roundToScale(int $scale, int $roundMode): string static::ROUND_HALF_ODD => $half > 0 || ($half === 0 && !$odd), default => $half >= 0, }; - if ($awayFromZero) { - $integralPart = bcadd($integralPart, '1', 0); - } - - return (string)(new static(($this->negative ? '-' : '') . $integralPart))->shift(-$scale); } /** @@ -842,7 +999,7 @@ public function allocate(array $ratios): array throw new InvalidArgumentException('Ratios must add up to more than zero.'); } - $scale = $this->scale(); + $scale = max($this->scale(), $this->fractionalLength()); $units = $this->absolute()->shift($scale)->integralPart; $shares = []; diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index df1a9e8..9585c31 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1702,8 +1702,283 @@ public function testGlobalBcScale(): void $this->assertSame('1.e3', (new Decimal('1000'))->toScientific()); $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); $this->assertTrue((new Decimal('4'))->isEven()); + $this->assertSame('0.67', (string)(new Decimal('2'))->divide('3', 2, Decimal::ROUND_HALF_UP)); + $this->assertSame('1.05', (string)(new Decimal('1.03'))->roundToStep('0.05')); + $this->assertSame(['3', '1'], array_map('strval', (new Decimal('7'))->divideWithRemainder(2))); } finally { bcscale($previous); } } + + /** + * @param string $a + * @param string $b + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('divideRoundedProvider')] + public function testDivideRounded(string $a, string $b, int $scale, int $mode, string $expected): void + { + $result = (new Decimal($a))->divide($b, $scale, $mode); + + $this->assertSame($expected, (string)$result); + $this->assertSame($scale, $result->scale()); + } + + /** + * @return array + */ + public static function divideRoundedProvider(): array + { + return [ + 'half up' => ['2', '3', 2, Decimal::ROUND_HALF_UP, '0.67'], + 'half up below' => ['1', '3', 2, Decimal::ROUND_HALF_UP, '0.33'], + 'half up negative' => ['-2', '3', 2, Decimal::ROUND_HALF_UP, '-0.67'], + 'both negative' => ['-2', '-3', 2, Decimal::ROUND_HALF_UP, '0.67'], + 'exact pads' => ['1', '4', 4, Decimal::ROUND_HALF_UP, '0.2500'], + 'tie half up' => ['1', '8', 2, Decimal::ROUND_HALF_UP, '0.13'], + 'tie half down' => ['1', '8', 2, Decimal::ROUND_HALF_DOWN, '0.12'], + 'tie half even' => ['1', '8', 2, Decimal::ROUND_HALF_EVEN, '0.12'], + 'tie half even up' => ['3', '8', 2, Decimal::ROUND_HALF_EVEN, '0.38'], + 'tie half odd' => ['1', '8', 2, Decimal::ROUND_HALF_ODD, '0.13'], + 'up' => ['1', '3', 2, Decimal::ROUND_UP, '0.34'], + 'down' => ['2', '3', 2, Decimal::ROUND_DOWN, '0.66'], + 'floor negative' => ['-1', '3', 2, Decimal::ROUND_FLOOR, '-0.34'], + 'ceil negative' => ['-2', '3', 2, Decimal::ROUND_CEIL, '-0.66'], + 'ceil positive' => ['1', '3', 2, Decimal::ROUND_CEIL, '0.34'], + 'to integer' => ['7', '2', 0, Decimal::ROUND_HALF_UP, '4'], + 'to integer even' => ['5', '2', 0, Decimal::ROUND_HALF_EVEN, '2'], + 'decimal operands' => ['19.99', '0.3', 3, Decimal::ROUND_HALF_UP, '66.633'], + 'small to zero' => ['-0.001', '3', 2, Decimal::ROUND_HALF_UP, '0.00'], + 'carry' => ['9.999', '1', 2, Decimal::ROUND_HALF_UP, '10.00'], + 'large' => ['100000000000000000000', '3', 1, Decimal::ROUND_HALF_UP, '33333333333333333333.3'], + ]; + } + + /** + * @return void + */ + public function testDivideWithoutModeCutsOff(): void + { + $this->assertSame('0.66', (string)(new Decimal('2'))->divide('3', 2)); + $this->assertSame('0.66', (string)(new Decimal('2'))->divide('3', 2, null)); + } + + /** + * @return void + */ + public function testDivideRoundedInvalidMode(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('2'))->divide('3', 2, PHP_ROUND_HALF_EVEN); + } + + /** + * @return void + */ + public function testDivideRoundedNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('2'))->divide('3', -1, Decimal::ROUND_HALF_UP); + } + + /** + * @return void + */ + public function testDivideRoundedByZero(): void + { + $this->expectException(DivisionByZeroError::class); + + (new Decimal('2'))->divide('0.0', 2, Decimal::ROUND_HALF_UP); + } + + /** + * @param string $a + * @param string $b + * @param string $quotient + * @param string $remainder + * + * @return void + */ + #[DataProvider('divideWithRemainderProvider')] + public function testDivideWithRemainder(string $a, string $b, string $quotient, string $remainder): void + { + $decimal = new Decimal($a); + [$q, $r] = $decimal->divideWithRemainder($b); + + $this->assertSame($quotient, (string)$q); + $this->assertSame($remainder, (string)$r); + $this->assertTrue($q->multiply($b)->add($r)->equals($decimal)); + } + + /** + * @return array + */ + public static function divideWithRemainderProvider(): array + { + return [ + 'integers' => ['7', '2', '3', '1'], + 'exact' => ['8', '2', '4', '0'], + 'negative dividend' => ['-7', '2', '-3', '-1'], + 'negative divisor' => ['7', '-2', '-3', '1'], + 'decimal divisor' => ['10', '0.3', '33', '0.1'], + 'decimal dividend' => ['10.50', '3', '3', '1.50'], + 'smaller than divisor' => ['1.5', '2', '0', '1.5'], + 'zero' => ['0.00', '3', '0', '0.00'], + ]; + } + + /** + * @return void + */ + public function testDivideWithRemainderByZero(): void + { + $this->expectException(DivisionByZeroError::class); + + (new Decimal('2'))->divideWithRemainder(0); + } + + /** + * @param string $value + * @param string $step + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('roundToStepProvider')] + public function testRoundToStep(string $value, string $step, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->roundToStep($step, $mode)); + } + + /** + * @return array + */ + public static function roundToStepProvider(): array + { + return [ + 'cash down' => ['1.02', '0.05', Decimal::ROUND_HALF_UP, '1.00'], + 'cash up' => ['1.03', '0.05', Decimal::ROUND_HALF_UP, '1.05'], + 'cash tie' => ['1.025', '0.05', Decimal::ROUND_HALF_UP, '1.05'], + 'cash tie even' => ['1.025', '0.05', Decimal::ROUND_HALF_EVEN, '1.00'], + 'cash negative' => ['-1.03', '0.05', Decimal::ROUND_HALF_UP, '-1.05'], + 'exact' => ['1.10', '0.05', Decimal::ROUND_UP, '1.10'], + 'up' => ['1.01', '0.05', Decimal::ROUND_UP, '1.05'], + 'floor' => ['1.09', '0.05', Decimal::ROUND_FLOOR, '1.05'], + 'quarter' => ['7.4', '0.25', Decimal::ROUND_HALF_UP, '7.50'], + 'integer step' => ['17', '5', Decimal::ROUND_HALF_UP, '15'], + 'integer step tie' => ['17.5', '5', Decimal::ROUND_HALF_UP, '20'], + 'to zero' => ['0.02', '0.05', Decimal::ROUND_HALF_UP, '0.00'], + ]; + } + + /** + * @param string $step + * + * @return void + */ + #[DataProvider('roundToStepInvalidProvider')] + public function testRoundToStepInvalid(string $step): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.02'))->roundToStep($step); + } + + /** + * @return array + */ + public static function roundToStepInvalidProvider(): array + { + return [ + 'zero' => ['0'], + 'negative' => ['-0.05'], + ]; + } + + /** + * @param float $value + * @param string $expected + * + * @return void + */ + #[DataProvider('createFromFloatProvider')] + public function testCreateFromFloat(float $value, string $expected): void + { + $decimal = Decimal::createFromFloat($value); + + $this->assertSame($expected, (string)$decimal); + $this->assertSame($value, (float)(string)$decimal); + } + + /** + * @return array + */ + public static function createFromFloatProvider(): array + { + return [ + 'simple' => [1.1, '1.1'], + 'integer' => [5.0, '5'], + 'negative' => [-23.44, '-23.44'], + 'zero' => [0.0, '0'], + 'beyond precision ini' => [1234567890.1234567, '1234567890.1234567'], + 'sum error visible' => [0.1 + 0.2, '0.30000000000000004'], + 'small' => [1.5e-7, '0.00000015'], + 'large' => [1e25, '10000000000000000000000000'], + ]; + } + + /** + * @return void + */ + public function testCreateFromFloatCommaLocale(): void + { + $previous = (string)setlocale(LC_NUMERIC, '0'); + if (setlocale(LC_NUMERIC, 'de_DE.UTF-8', 'de_DE.utf8', 'de_DE') === false) { + $this->markTestSkipped('No locale with a decimal comma installed.'); + } + + try { + $this->assertSame('1.1', (string)Decimal::createFromFloat(1.1)); + } finally { + setlocale(LC_NUMERIC, $previous); + } + } + + /** + * @return void + */ + public function testCreateFromFloatNonFinite(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::createFromFloat(INF); + } + + /** + * @return void + */ + public function testZeroAndOne(): void + { + $this->assertSame('0', (string)Decimal::zero()); + $this->assertSame('1', (string)Decimal::one()); + $this->assertTrue(Decimal::zero()->isZero()); + } + + /** + * @return void + */ + public function testAllocateWithZeroScaleDeclared(): void + { + $decimal = new Decimal('1.5', 0); + $shares = $decimal->split(2); + + $this->assertSame(['0.8', '0.7'], array_map('strval', $shares)); + } }