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)); + } }