[0-9]+';
- private const string VALID_NUMBER = '/^(%s)?(?:(?:(%s)?(%s)?(%s)?(?:[eE](%s))?)|(?:(%s)\/?(%s)))$/';
-
- private int $match;
-
- private array $matches = [];
-
- private function __construct(public readonly string $value)
- {
- $pattern = sprintf(
- self::VALID_NUMBER,
- self::SIGN,
- self::INTEGRAL,
- self::POINT,
- self::FRACTIONAL,
- self::EXPONENT,
- self::NUMERATOR,
- self::DENOMINATOR
- );
-
- $this->match = preg_match($pattern, $this->value, $this->matches);
-
- if ($this->isInvalidNumber()) {
- throw new InvalidNumber(value: $this->value);
- }
- }
-
- public static function from(float|string $value): Number
- {
- if (is_float($value) && is_nan($value)) {
- throw new InvalidNumber(value: "NAN");
- }
-
- return new Number(value: (string)$value);
- }
-
- public function getExponent(): ?string
- {
- return $this->match(key: 'exponent');
- }
-
- public function getFractional(): ?string
- {
- return $this->match(key: 'fractional');
- }
-
- public function isZero(): bool
- {
- return $this->value == self::ZERO;
- }
-
- public function isNegative(): bool
- {
- return $this->value < self::ZERO;
- }
-
- public function isPositiveOrZero(): bool
- {
- return $this->isZero() || !$this->isNegative();
- }
-
- public function isNegativeOrZero(): bool
- {
- return $this->isZero() || $this->isNegative();
- }
-
- public function isLessThan(Number $other): bool
- {
- return $this->value < $other->value;
- }
-
- public function isGreaterThan(Number $other): bool
- {
- return $this->value > $other->value;
- }
-
- public function isLessThanOrEqual(Number $other): bool
- {
- return $this->value <= $other->value;
- }
-
- public function isGreaterThanOrEqual(Number $other): bool
- {
- return $this->value >= $other->value;
- }
-
- public function toFloatWithScale(Scale $scale): float
- {
- $value = (float)$this->value;
-
- return $scale->hasAutomaticScale() ? $value : (float)number_format($value, $scale->value, '.', '');
- }
-
- private function match(string $key): ?string
- {
- $math = $this->matches[$key] ?? null;
-
- return $math == '' ? null : $math;
- }
-
- private function isInvalidNumber(): bool
- {
- $integral = $this->match(key: 'integral');
-
- return ($this->match !== 1) || (is_null($integral) && is_null($this->getFractional()));
- }
-}
diff --git a/src/Internal/NumberComparison.php b/src/Internal/NumberComparison.php
new file mode 100644
index 0000000..ff0dc63
--- /dev/null
+++ b/src/Internal/NumberComparison.php
@@ -0,0 +1,46 @@
+compareTo(other: $other) === 0;
+ }
+
+ public function isLessThan(Number $other): bool
+ {
+ return $this->compareTo(other: $other) < 0;
+ }
+
+ abstract public function isNegative(): bool;
+
+ public function isPositive(): bool
+ {
+ return !$this->isZero() && !$this->isNegative();
+ }
+
+ public function isGreaterThan(Number $other): bool
+ {
+ return $this->compareTo(other: $other) > 0;
+ }
+
+ public function isLessThanOrEqualTo(Number $other): bool
+ {
+ return $this->compareTo(other: $other) <= 0;
+ }
+
+ public function isGreaterThanOrEqualTo(Number $other): bool
+ {
+ return $this->compareTo(other: $other) >= 0;
+ }
+}
diff --git a/src/Internal/Operations/Adapters/BcMathAdapter.php b/src/Internal/Operations/Adapters/BcMathAdapter.php
deleted file mode 100644
index d8b8186..0000000
--- a/src/Internal/Operations/Adapters/BcMathAdapter.php
+++ /dev/null
@@ -1,72 +0,0 @@
-applyScale();
- $number = Number::from(
- value: bcadd(
- $augend->toString(),
- $addend->toString(),
- $scale->value
- )
- );
-
- return new Result(number: $number, scale: $scale);
- }
-
- public function subtract(BigNumber $minuend, BigNumber $subtrahend): Result
- {
- $scale = new Subtraction(minuend: $minuend, subtrahend: $subtrahend)->applyScale();
- $number = Number::from(
- value: bcsub(
- $minuend->toString(),
- $subtrahend->toString(),
- $scale->value
- )
- );
-
- return new Result(number: $number, scale: $scale);
- }
-
- public function multiply(BigNumber $multiplicand, BigNumber $multiplier): Result
- {
- $scale = new Multiplication(multiplicand: $multiplicand, multiplier: $multiplier)->applyScale();
- $number = Number::from(
- value: bcmul(
- $multiplicand->toString(),
- $multiplier->toString(),
- $scale->value
- )
- );
-
- return new Result(number: $number, scale: $scale);
- }
-
- public function divide(BigNumber $dividend, BigNumber $divisor): Result
- {
- $scale = new Division(dividend: $dividend, divisor: $divisor)->applyScale();
- $number = Number::from(
- value: bcdiv(
- $dividend->toString(),
- $divisor->toString(),
- $scale->value
- )
- );
-
- return new Result(number: $number, scale: $scale);
- }
-}
diff --git a/src/Internal/Operations/Adapters/Result.php b/src/Internal/Operations/Adapters/Result.php
deleted file mode 100644
index c4d329f..0000000
--- a/src/Internal/Operations/Adapters/Result.php
+++ /dev/null
@@ -1,15 +0,0 @@
-augendScale = Scale::from(value: $this->augend->getScale());
- $this->addendScale = Scale::from(value: $this->addend->getScale());
- }
-
- public function applyScale(): Scale
- {
- if ($this->augendScale->hasAutomaticScale() && $this->addendScale->hasAutomaticScale()) {
- $augendScale = $this->augendScale->scaleOf(value: $this->augend->toString());
- $addendScale = $this->addendScale->scaleOf(value: $this->addend->toString());
-
- return $augendScale->greaterScale(other: $addendScale);
- }
-
- return $this->augendScale->greaterScale(other: $this->addendScale);
- }
-}
diff --git a/src/Internal/Operations/Adapters/Scales/Division.php b/src/Internal/Operations/Adapters/Scales/Division.php
deleted file mode 100644
index 370bb06..0000000
--- a/src/Internal/Operations/Adapters/Scales/Division.php
+++ /dev/null
@@ -1,32 +0,0 @@
-dividendScale = Scale::from(value: $this->dividend->getScale());
- $this->divisorScale = Scale::from(value: $this->divisor->getScale());
- }
-
- public function applyScale(): Scale
- {
- if ($this->dividendScale->hasAutomaticScale() && $this->divisorScale->hasAutomaticScale()) {
- $quotient = $this->dividend->toString() / $this->divisor->toString();
-
- return $this->dividendScale->scaleOf(value: (string)$quotient);
- }
-
- return $this->dividendScale->greaterScale(other: $this->divisorScale);
- }
-}
diff --git a/src/Internal/Operations/Adapters/Scales/Multiplication.php b/src/Internal/Operations/Adapters/Scales/Multiplication.php
deleted file mode 100644
index 37edd7e..0000000
--- a/src/Internal/Operations/Adapters/Scales/Multiplication.php
+++ /dev/null
@@ -1,37 +0,0 @@
-multiplierScale = Scale::from(value: $this->multiplier->getScale());
- $this->multiplicandScale = Scale::from(value: $this->multiplicand->getScale());
- }
-
- public function applyScale(): Scale
- {
- if ($this->multiplicandScale->hasAutomaticScale() && $this->multiplierScale->hasAutomaticScale()) {
- $multiplicandScale = $this->multiplicandScale->scaleOf(value: $this->multiplicand->toString());
- $multiplierScale = $this->multiplierScale->scaleOf(value: $this->multiplier->toString());
-
- if ($multiplicandScale->equals(other: $multiplierScale)) {
- return $multiplicandScale->add(other: $multiplierScale);
- }
-
- return $multiplicandScale->greaterScale(other: $multiplierScale);
- }
-
- return $this->multiplicandScale->greaterScale(other: $this->multiplierScale);
- }
-}
diff --git a/src/Internal/Operations/Adapters/Scales/Scales.php b/src/Internal/Operations/Adapters/Scales/Scales.php
deleted file mode 100644
index bf96168..0000000
--- a/src/Internal/Operations/Adapters/Scales/Scales.php
+++ /dev/null
@@ -1,22 +0,0 @@
-minuendScale = Scale::from(value: $this->minuend->getScale());
- $this->subtrahendScale = Scale::from(value: $this->subtrahend->getScale());
- }
-
- public function applyScale(): Scale
- {
- if ($this->minuendScale->hasAutomaticScale() && $this->subtrahendScale->hasAutomaticScale()) {
- $minuendScale = $this->minuendScale->scaleOf(value: $this->minuend->toString());
- $subtrahendScale = $this->subtrahendScale->scaleOf(value: $this->subtrahend->toString());
-
- return $minuendScale->greaterScale(other: $subtrahendScale);
- }
-
- return $this->minuendScale->greaterScale(other: $this->subtrahendScale);
- }
-}
diff --git a/src/Internal/Operations/Extension/Extension.php b/src/Internal/Operations/Extension/Extension.php
deleted file mode 100644
index 65d40b2..0000000
--- a/src/Internal/Operations/Extension/Extension.php
+++ /dev/null
@@ -1,21 +0,0 @@
-extension->isAvailable(extension: Extension::BC_MATH)) {
- return new BcMathAdapter();
- }
-
- throw new MathOperationsNotAvailable(extensions: [Extension::BC_MATH]);
- }
-}
diff --git a/src/Internal/Scale.php b/src/Internal/Scale.php
deleted file mode 100644
index 3207bfa..0000000
--- a/src/Internal/Scale.php
+++ /dev/null
@@ -1,80 +0,0 @@
-hasAutomaticScale() && ($this->value < self::MINIMUM || $this->value > self::MAXIMUM)) {
- throw new InvalidScale(value: $this->value, minimum: self::MINIMUM, maximum: self::MAXIMUM);
- }
- }
-
- public static function from(?int $value): Scale
- {
- return new Scale(value: $value);
- }
-
- public function scaleOf(string $value): Scale
- {
- $number = Number::from(value: $value);
-
- $exponent = $number->getExponent();
- $exponent = is_null($exponent) ? self::MINIMUM : $exponent;
-
- $fractional = $number->getFractional();
- $fractional = is_null($fractional) ? self::MINIMUM : strlen($fractional);
-
- return new Scale(value: max($fractional - $exponent, self::MINIMUM));
- }
-
- public function numberWithScale(Number $number, int $scale): Number
- {
- if ($number->isZero()) {
- $formattedValue = number_format(0, $scale, '.', '');
- return Number::from(value: $formattedValue);
- }
-
- $result = explode('.', $number->value);
- $decimal = $result[0];
-
- $places = substr($result[self::FIRST_DECIMAL_PLACE], self::ZERO_DECIMAL_PLACE, $scale);
- $decimalPlaces = str_pad($places, $scale, '0');
-
- $template = '%s.%s';
- $value = sprintf($template, $decimal, $decimalPlaces);
-
- return Number::from(value: $value);
- }
-
- public function add(Scale $other): Scale
- {
- return new Scale(value: $this->value + $other->value);
- }
-
- public function greaterScale(Scale $other): Scale
- {
- return new Scale(value: max($this->value, $other->value));
- }
-
- public function equals(Scale $other): bool
- {
- return $this->value == $other->value;
- }
-
- public function hasAutomaticScale(): bool
- {
- return $this->value === BigNumber::AUTOMATIC_SCALE;
- }
-}
diff --git a/src/Internal/StructuralHash.php b/src/Internal/StructuralHash.php
new file mode 100644
index 0000000..fbba398
--- /dev/null
+++ b/src/Internal/StructuralHash.php
@@ -0,0 +1,17 @@
+isPositiveOrZero()) {
- throw new NonNegativeValue(number: $number);
- }
-
- parent::__construct(number: $number, scale: $scale);
- }
-
- public static function fromFloat(float $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): NegativeBigDecimal
- {
- return new NegativeBigDecimal(value: $value, scale: $scale);
- }
-
- public static function fromString(string $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): NegativeBigDecimal
- {
- return new NegativeBigDecimal(value: $value, scale: $scale);
- }
-}
diff --git a/src/Number.php b/src/Number.php
new file mode 100644
index 0000000..2fc038b
--- /dev/null
+++ b/src/Number.php
@@ -0,0 +1,142 @@
+Two notions of sameness live here and they are deliberately named apart.
+ * isEqualTo is arithmetic and works across types, so 1.0 and
+ * 1.00 are equal. Each implementation also carries an equals of its own,
+ * typed to its exact class, which is structural and reads the representation, so the same pair is
+ * not equal because the scales differ. Java documents this trap three times because it exposes
+ * only one of the two under an ambiguous name.
+ *
+ * Serialization is a JSON string rather than a JSON number, because a JSON number is read back
+ * as an IEEE-754 double and would discard exactly what this library preserves.
+ */
+interface Number extends JsonSerializable
+{
+ /**
+ * Tells whether this number is zero.
+ *
+ * @return bool True when the value is zero, at any scale.
+ */
+ public function isZero(): bool;
+
+ /**
+ * Returns this number with the opposite sign.
+ *
+ * @return Number A new instance with the sign flipped.
+ */
+ public function negated(): Number;
+
+ /**
+ * Returns the magnitude of this number.
+ *
+ * @return Number A new instance with the sign removed.
+ */
+ public function absolute(): Number;
+
+ /**
+ * Returns a deterministic hash of this number's representation.
+ *
+ * Agrees with the implementation's own equals, so two numbers that are
+ * structurally equal share a hash. Two numbers that are only arithmetically equal, such as
+ * 1.0 and 1.00, do not.
+ *
+ * @return string The structural hash.
+ */
+ public function hashCode(): string;
+
+ /**
+ * Returns this number as a string.
+ *
+ * Always positional notation, never scientific. The output round-trips through the
+ * originating type's own factory.
+ *
+ * @return string The canonical string form.
+ */
+ public function toString(): string;
+
+ /**
+ * Compares this number with another.
+ *
+ * Exact whatever the pair of types. Two numbers of the same type are compared on their own
+ * representation. Across types the comparison goes through {@see BigRational}, the only form
+ * every number has exactly.
+ *
+ * @param Number $other The number to compare against.
+ * @return int A negative number, zero, or a positive number.
+ */
+ public function compareTo(Number $other): int;
+
+ /**
+ * Tells whether this number has the same value as another.
+ *
+ * Arithmetic equality, so scale is irrelevant and the two numbers need not share a type.
+ * For structural equality that distinguishes 1.0 from 1.00, use the
+ * implementation's own equals, which accepts only its exact type.
+ *
+ * @param Number $other The number to compare against.
+ * @return bool True when both represent the same value.
+ */
+ public function isEqualTo(Number $other): bool;
+
+ /**
+ * Tells whether this number is strictly less than another.
+ *
+ * @param Number $other The number to compare against.
+ * @return bool True when this number is smaller.
+ */
+ public function isLessThan(Number $other): bool;
+
+ /**
+ * Tells whether this number is strictly less than zero.
+ *
+ * @return bool True when the value is negative.
+ */
+ public function isNegative(): bool;
+
+ /**
+ * Tells whether this number is strictly greater than zero.
+ *
+ * @return bool True when the value is positive.
+ */
+ public function isPositive(): bool;
+
+ /**
+ * Tells whether this number is strictly greater than another.
+ *
+ * @param Number $other The number to compare against.
+ * @return bool True when this number is larger.
+ */
+ public function isGreaterThan(Number $other): bool;
+
+ /**
+ * Returns this number as an exact fraction.
+ *
+ * @return BigRational The exact rational representation.
+ */
+ public function toBigRational(): BigRational;
+
+ /**
+ * Tells whether this number is less than or equal to another.
+ *
+ * @param Number $other The number to compare against.
+ * @return bool True when this number is smaller or equal.
+ */
+ public function isLessThanOrEqualTo(Number $other): bool;
+
+ /**
+ * Tells whether this number is greater than or equal to another.
+ *
+ * @param Number $other The number to compare against.
+ * @return bool True when this number is larger or equal.
+ */
+ public function isGreaterThanOrEqualTo(Number $other): bool;
+}
diff --git a/src/Percentage.php b/src/Percentage.php
new file mode 100644
index 0000000..a56baac
--- /dev/null
+++ b/src/Percentage.php
@@ -0,0 +1,265 @@
+Percentage::of(value: '12.5') is twelve and a half percent. The value is held as
+ * a {@see BigDecimal}, so applying it to an amount is a multiplication and therefore exact: no
+ * rounding decision is forced on the caller until the result is presented.
+ *
+ * Negative percentages and percentages above one hundred are legal, because a negative growth
+ * rate and a one hundred and fifty percent increase are both real. There is no invariant to
+ * enforce here and therefore no failure to raise.
+ */
+final readonly class Percentage implements JsonSerializable
+{
+ private const string SIGN = '%';
+ private const string ZERO = '0';
+ private const int PLACES_IN_A_HUNDRED = 2;
+
+ private function __construct(private BigDecimal $percent)
+ {
+ }
+
+ /**
+ * Creates a Percentage from a literal expressed per hundred.
+ *
+ * @param string|int $value The literal, where '12.5' means twelve and a half percent.
+ * A trailing percent sign is accepted, so the string form reads back.
+ * @return Percentage The created instance.
+ * @throws NumberNotWellFormed If the literal is not a number, or its exponent exceeds 10000 in magnitude.
+ * @throws ScaleOutOfRange If the literal implies a scale beyond the supported range.
+ */
+ public static function of(string|int $value): Percentage
+ {
+ $literal = (string)$value;
+ $rate = str_ends_with($literal, self::SIGN) ? substr($literal, 0, -1) : $literal;
+
+ return new Percentage(percent: BigDecimal::of(value: $rate));
+ }
+
+ /**
+ * Creates a Percentage holding zero.
+ *
+ * @return Percentage The created instance.
+ */
+ public static function zero(): Percentage
+ {
+ return Percentage::of(value: self::ZERO);
+ }
+
+ /**
+ * Creates a Percentage from a ratio, rounded to the given scale.
+ *
+ * A scale is required because a proportion such as one third has no exact percentage.
+ *
+ * @param Ratio $ratio The proportion to express per hundred.
+ * @param int $scale The number of digits after the decimal point in the percentage.
+ * @param RoundingMode $rounding The policy applied to the discarded digits.
+ * @return Percentage The created instance.
+ * @throws ScaleOutOfRange If the scale is negative or beyond the supported range.
+ */
+ public static function fromRatio(Ratio $ratio, int $scale, RoundingMode $rounding): Percentage
+ {
+ return $ratio->toPercentage(scale: $scale, rounding: $rounding);
+ }
+
+ /**
+ * Returns this percentage as the factor it multiplies by.
+ *
+ * Twelve and a half percent reads back as 0.125.
+ *
+ * @return BigDecimal The factor, at this percentage's scale plus two.
+ */
+ public function rate(): BigDecimal
+ {
+ return BigDecimal::ofUnscaledValue(
+ scale: ($this->percent->scale() + self::PLACES_IN_A_HUNDRED),
+ unscaled: $this->percent->unscaledValue()
+ );
+ }
+
+ /**
+ * Tells whether this percentage holds the same value and scale as another percentage.
+ *
+ * Structural equality, so ten percent written '10' does not equal the same rate
+ * written '10.0'. For arithmetic equality, use
+ * {@see Percentage::isEqualTo()}.
+ *
+ * @param Percentage $other The percentage to compare against.
+ * @return bool True when both hold the same value at the same scale.
+ */
+ public function equals(Percentage $other): bool
+ {
+ return $other->percent->equals(other: $this->percent);
+ }
+
+ /**
+ * Tells whether this percentage is zero.
+ *
+ * @return bool True when the value is zero.
+ */
+ public function isZero(): bool
+ {
+ return $this->percent->isZero();
+ }
+
+ /**
+ * Applies this percentage to an amount.
+ *
+ * @param BigDecimal $amount The amount to take a percentage of.
+ * @return BigDecimal The exact result, at the amount's scale plus this percentage's scale plus two.
+ */
+ public function applyTo(BigDecimal $amount): BigDecimal
+ {
+ return $amount->multipliedBy(multiplier: $this->rate());
+ }
+
+ /**
+ * Returns this percentage as a ratio.
+ *
+ * @return Ratio The equivalent proportion, in lowest terms.
+ */
+ public function toRatio(): Ratio
+ {
+ return Ratio::between(antecedent: $this->rate(), consequent: BigDecimal::one());
+ }
+
+ /**
+ * Reduces an amount by this percentage.
+ *
+ * @param BigDecimal $amount The amount to reduce.
+ * @return BigDecimal The exact result of the amount minus this percentage of it.
+ */
+ public function decrease(BigDecimal $amount): BigDecimal
+ {
+ return $amount->minus(subtrahend: $this->applyTo(amount: $amount));
+ }
+
+ /**
+ * Returns a deterministic hash of this percentage.
+ *
+ * @return string The structural hash.
+ */
+ public function hashCode(): string
+ {
+ return new StructuralHash()->of(type: Percentage::class, representation: $this->toString());
+ }
+
+ /**
+ * Raises an amount by this percentage.
+ *
+ * @param BigDecimal $amount The amount to raise.
+ * @return BigDecimal The exact result of the amount plus this percentage of it.
+ */
+ public function increase(BigDecimal $amount): BigDecimal
+ {
+ return $amount->plus(addend: $this->applyTo(amount: $amount));
+ }
+
+ /**
+ * Returns this percentage as a string.
+ *
+ * @return string The value followed by a percent sign, such as 12.5%.
+ */
+ public function toString(): string
+ {
+ $template = '%s%%';
+
+ return sprintf($template, $this->percent->toString());
+ }
+
+ /**
+ * Tells whether this percentage has the same value as another.
+ *
+ * @param Percentage $other The percentage to compare against.
+ * @return bool True when both represent the same rate, at any scale.
+ */
+ public function isEqualTo(Percentage $other): bool
+ {
+ return $this->percent->isEqualTo(other: $other->percent);
+ }
+
+ /**
+ * Tells whether this percentage is strictly less than another.
+ *
+ * @param Percentage $other The percentage to compare against.
+ * @return bool True when this rate is smaller.
+ */
+ public function isLessThan(Percentage $other): bool
+ {
+ return $this->percent->isLessThan(other: $other->percent);
+ }
+
+ /**
+ * Tells whether this percentage is strictly less than zero.
+ *
+ * @return bool True when the rate is negative.
+ */
+ public function isNegative(): bool
+ {
+ return $this->percent->isNegative();
+ }
+
+ /**
+ * Tells whether this percentage is strictly greater than zero.
+ *
+ * @return bool True when the rate is positive.
+ */
+ public function isPositive(): bool
+ {
+ return $this->percent->isPositive();
+ }
+
+ /**
+ * Tells whether this percentage is strictly greater than another.
+ *
+ * @param Percentage $other The percentage to compare against.
+ * @return bool True when this rate is larger.
+ */
+ public function isGreaterThan(Percentage $other): bool
+ {
+ return $this->percent->isGreaterThan(other: $other->percent);
+ }
+
+ /**
+ * Returns this percentage as a JSON string.
+ *
+ * @return string The canonical string form, percent sign included.
+ */
+ public function jsonSerialize(): string
+ {
+ return $this->toString();
+ }
+
+ /**
+ * Tells whether this percentage is less than or equal to another.
+ *
+ * @param Percentage $other The percentage to compare against.
+ * @return bool True when this rate is smaller or equal.
+ */
+ public function isLessThanOrEqualTo(Percentage $other): bool
+ {
+ return $this->percent->isLessThanOrEqualTo(other: $other->percent);
+ }
+
+ /**
+ * Tells whether this percentage is greater than or equal to another.
+ *
+ * @param Percentage $other The percentage to compare against.
+ * @return bool True when this rate is larger or equal.
+ */
+ public function isGreaterThanOrEqualTo(Percentage $other): bool
+ {
+ return $this->percent->isGreaterThanOrEqualTo(other: $other->percent);
+ }
+}
diff --git a/src/PositiveBigDecimal.php b/src/PositiveBigDecimal.php
deleted file mode 100644
index 26f23ae..0000000
--- a/src/PositiveBigDecimal.php
+++ /dev/null
@@ -1,35 +0,0 @@
-isNegativeOrZero()) {
- throw new NonPositiveValue(number: $number);
- }
-
- parent::__construct(number: $number, scale: $scale);
- }
-
- public static function fromFloat(float $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): PositiveBigDecimal
- {
- return new PositiveBigDecimal(value: $value, scale: $scale);
- }
-
- public static function fromString(string $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): PositiveBigDecimal
- {
- return new PositiveBigDecimal(value: $value, scale: $scale);
- }
-}
diff --git a/src/Ratio.php b/src/Ratio.php
new file mode 100644
index 0000000..4813a0a
--- /dev/null
+++ b/src/Ratio.php
@@ -0,0 +1,212 @@
+Held as a {@see BigRational} in lowest terms, so 4:2 and 2:1 are the
+ * same ratio. Where a {@see BigRational} is a quantity, a Ratio is a relationship: it is applied to
+ * an amount rather than added to one, and it reads back as 16:9.
+ *
+ * {@see Ratio::between()} derives a proportion from two arbitrary numbers without
+ * anyone naming a scale, because the result stays exact.
+ */
+final readonly class Ratio implements JsonSerializable
+{
+ private const string HUNDRED = '100';
+ private const string SEPARATOR = ':';
+
+ private function __construct(private BigRational $proportion)
+ {
+ }
+
+ /**
+ * Creates a Ratio from two integer terms.
+ *
+ * @param int|string $antecedent The first term.
+ * @param int|string $consequent The second term, never zero.
+ * @return Ratio The created instance.
+ * @throws NumberNotWellFormed If either term is not a number.
+ * @throws InexactConversion If either term carries a non-zero fractional part.
+ * @throws DivisionByZero If the consequent is zero.
+ */
+ public static function of(int|string $antecedent, int|string $consequent): Ratio
+ {
+ return new Ratio(
+ proportion: BigRational::ofFraction(
+ numerator: BigInteger::of(value: $antecedent),
+ denominator: BigInteger::of(value: $consequent)
+ )
+ );
+ }
+
+ /**
+ * Creates a Ratio from its two terms written as one string.
+ *
+ * Reads the form {@see Ratio::toString()} emits, so
+ * '16:9' reads back as the ratio it came from.
+ *
+ * @param string $value The two terms separated by a colon.
+ * @return Ratio The created instance.
+ * @throws NumberNotWellFormed If the string does not carry exactly two terms, or either is not a number.
+ * @throws InexactConversion If either term carries a non-zero fractional part.
+ * @throws DivisionByZero If the consequent is zero.
+ */
+ public static function from(string $value): Ratio
+ {
+ $terms = explode(self::SEPARATOR, $value);
+
+ if (count($terms) !== 2) {
+ throw NumberNotWellFormed::becauseValueIsNotNumeric(value: $value);
+ }
+
+ return Ratio::of(antecedent: $terms[0], consequent: $terms[1]);
+ }
+
+ /**
+ * Creates a Ratio from two numbers of any kind.
+ *
+ * Exact, so no scale and no rounding mode are involved.
+ *
+ * @param Number $antecedent The first quantity.
+ * @param Number $consequent The second quantity, never zero.
+ * @return Ratio The created instance.
+ * @throws DivisionByZero If the consequent is zero.
+ */
+ public static function between(Number $antecedent, Number $consequent): Ratio
+ {
+ return new Ratio(
+ proportion: $antecedent->toBigRational()->dividedBy(divisor: $consequent->toBigRational())
+ );
+ }
+
+ /**
+ * Tells whether this ratio holds the same proportion as another ratio.
+ *
+ * Structural equality. Because a ratio is always stored in lowest terms,
+ * 32:18 equals 16:9.
+ *
+ * @param Ratio $other The ratio to compare against.
+ * @return bool True when both hold the same proportion.
+ */
+ public function equals(Ratio $other): bool
+ {
+ return $other->proportion->equals(other: $this->proportion);
+ }
+
+ /**
+ * Applies this ratio to an amount.
+ *
+ * @param Number $amount The amount to scale.
+ * @return BigRational The exact result of the amount times the ratio.
+ */
+ public function applyTo(Number $amount): BigRational
+ {
+ return $this->proportion->multipliedBy(multiplier: $amount->toBigRational());
+ }
+
+ /**
+ * Returns a deterministic hash of this ratio.
+ *
+ * @return string The structural hash.
+ */
+ public function hashCode(): string
+ {
+ return new StructuralHash()->of(type: Ratio::class, representation: $this->toString());
+ }
+
+ /**
+ * Returns this ratio with its two terms swapped.
+ *
+ * @return Ratio A new instance holding the inverse proportion.
+ * @throws DivisionByZero If the antecedent is zero.
+ */
+ public function inverted(): Ratio
+ {
+ return new Ratio(proportion: $this->proportion->reciprocal());
+ }
+
+ /**
+ * Returns this ratio as a string.
+ *
+ * @return string The two terms separated by a colon, such as 16:9.
+ */
+ public function toString(): string
+ {
+ $template = '%s:%s';
+
+ return sprintf(
+ $template,
+ $this->proportion->numerator()->toString(),
+ $this->proportion->denominator()->toString()
+ );
+ }
+
+ /**
+ * Returns the first term of this ratio.
+ *
+ * @return BigInteger The antecedent, carrying the sign of the ratio.
+ */
+ public function antecedent(): BigInteger
+ {
+ return $this->proportion->numerator();
+ }
+
+ /**
+ * Returns the second term of this ratio.
+ *
+ * @return BigInteger The consequent, always strictly positive.
+ */
+ public function consequent(): BigInteger
+ {
+ return $this->proportion->denominator();
+ }
+
+ /**
+ * Returns this ratio as a percentage, rounded to the given scale.
+ *
+ * @param int $scale The number of digits after the decimal point in the percentage.
+ * @param RoundingMode $rounding The policy applied to the discarded digits.
+ * @return Percentage The equivalent percentage.
+ * @throws ScaleOutOfRange If the scale is negative or beyond the supported range.
+ */
+ public function toPercentage(int $scale, RoundingMode $rounding): Percentage
+ {
+ return Percentage::of(
+ value: $this->proportion
+ ->multipliedBy(multiplier: BigRational::of(value: self::HUNDRED))
+ ->toDecimal(scale: $scale, rounding: $rounding)
+ ->toString()
+ );
+ }
+
+ /**
+ * Returns this ratio as a JSON string.
+ *
+ * @return string The canonical string form, such as 16:9.
+ */
+ public function jsonSerialize(): string
+ {
+ return $this->toString();
+ }
+
+ /**
+ * Returns this ratio as an exact fraction.
+ *
+ * @return BigRational The proportion, in lowest terms.
+ */
+ public function toBigRational(): BigRational
+ {
+ return $this->proportion;
+ }
+}
diff --git a/src/RoundingMode.php b/src/RoundingMode.php
index 9ce7b34..7a5fadf 100644
--- a/src/RoundingMode.php
+++ b/src/RoundingMode.php
@@ -4,67 +4,99 @@
namespace TinyBlocks\Math;
-use TinyBlocks\Math\Internal\Number;
-use TinyBlocks\Math\Internal\Scale;
+use RoundingMode as NativeRoundingMode;
/**
- * Use one of the following constants to specify the mode in which rounding occurs.
+ * Policy deciding how a discarded fraction affects the last digit kept.
*
- * @see https://www.php.net/manual/en/function.round.php
+ * Nothing in this library rounds implicitly, so a mode is named only where a conversion is
+ * asked for. There is no case meaning "do not round": that path is
+ * {@see BigDecimal::toScaleExact()} and
+ * {@see BigRational::toDecimalExact()}, which raise instead of guessing.
+ *
+ * Each case maps one to one onto PHP's native RoundingMode, so a policy crosses that
+ * boundary in either direction. The rounding itself is decided here and applied exactly on integers,
+ * never by PHP's float round(). The names follow the vocabulary used by the literature
+ * on monetary rounding, and the backing values are stable strings so a policy survives a round trip
+ * through configuration.
*/
-enum RoundingMode: int
+enum RoundingMode: string
{
- case HALF_UP = 1;
- case HALF_ODD = 4;
- case HALF_EVEN = 3;
- case HALF_DOWN = 2;
-
- private const int ODD_CORRECTION = 1;
- private const int EVEN_CHECK_MODULO = 2;
- private const float HALF_ODD_EVEN_THRESHOLD = 0.5;
+ case Up = 'up';
+ case Down = 'down';
+ case Floor = 'floor';
+ case HalfUp = 'half-up';
+ case Ceiling = 'ceiling';
+ case HalfOdd = 'half-odd';
+ case HalfDown = 'half-down';
+ case HalfEven = 'half-even';
- public function round(BigNumber $bigNumber): Number
+ /**
+ * Creates a RoundingMode from PHP's native rounding mode.
+ *
+ * @param NativeRoundingMode $mode The native mode to translate.
+ * @return RoundingMode The equivalent case.
+ */
+ public static function fromNativeRoundingMode(NativeRoundingMode $mode): RoundingMode
{
- $precision = $bigNumber->getScale() ?? Scale::MINIMUM;
- $factor = 10 ** $precision;
- $value = $bigNumber->toFloat();
-
- $roundedValue = match ($this) {
- self::HALF_UP => $this->roundHalfUp(value: $value, precision: $precision),
- self::HALF_ODD => $this->roundHalfOdd(value: $value, factor: $factor),
- self::HALF_EVEN => $this->roundHalfEven(value: $value, precision: $precision),
- self::HALF_DOWN => $this->roundHalfDown(value: $value, factor: $factor)
+ return match ($mode) {
+ NativeRoundingMode::AwayFromZero => RoundingMode::Up,
+ NativeRoundingMode::TowardsZero => RoundingMode::Down,
+ NativeRoundingMode::NegativeInfinity => RoundingMode::Floor,
+ NativeRoundingMode::HalfAwayFromZero => RoundingMode::HalfUp,
+ NativeRoundingMode::PositiveInfinity => RoundingMode::Ceiling,
+ NativeRoundingMode::HalfOdd => RoundingMode::HalfOdd,
+ NativeRoundingMode::HalfTowardsZero => RoundingMode::HalfDown,
+ NativeRoundingMode::HalfEven => RoundingMode::HalfEven
};
-
- return Number::from($roundedValue);
}
- private function roundHalfUp(float $value, int $precision): float
+ /**
+ * Tells whether a discarded fraction pushes the kept digit away from zero.
+ *
+ * This is the whole definition of a rounding mode, expressed the way Java's
+ * RoundingMode javadoc defines each constant: from how the discarded part compares
+ * with one half, the sign of the value, and the parity of the digit being kept.
+ *
+ * A comparison of zero is a tie. The two tie-breaking modes raise their threshold by one
+ * when the tie should stay put, so HalfEven keeps an even digit and
+ * HalfOdd keeps an odd one.
+ *
+ * @param bool $isEven Whether the last kept digit is even.
+ * @param int $comparison How the discarded fraction compares with one half, never zero digits.
+ * @param bool $isNegative Whether the value is negative.
+ * @return bool True when the magnitude grows.
+ */
+ public function roundsAwayFromZero(bool $isEven, int $comparison, bool $isNegative): bool
{
- return round($value, $precision);
- }
-
- private function roundHalfOdd(float $value, int $factor): float
- {
- $scaledValue = $value * $factor;
- $rounded = round($scaledValue);
-
- if ($rounded % self::EVEN_CHECK_MODULO === 0) {
- $rounded += ($scaledValue > $rounded) ? self::ODD_CORRECTION : -self::ODD_CORRECTION;
- }
-
- return $rounded / $factor;
- }
-
- private function roundHalfEven(float $value, int $precision): float
- {
- return round($value, $precision, PHP_ROUND_HALF_EVEN);
+ return match ($this) {
+ RoundingMode::Up => true,
+ RoundingMode::Down => false,
+ RoundingMode::Floor => $isNegative,
+ RoundingMode::HalfUp => $comparison >= 0,
+ RoundingMode::Ceiling => !$isNegative,
+ RoundingMode::HalfOdd => $comparison >= intval(!$isEven),
+ RoundingMode::HalfDown => $comparison > 0,
+ RoundingMode::HalfEven => $comparison >= intval($isEven)
+ };
}
- private function roundHalfDown(float $value, int $factor): float
+ /**
+ * Returns the native rounding mode equivalent to this case.
+ *
+ * @return NativeRoundingMode The equivalent native mode.
+ */
+ public function toNativeRoundingMode(): NativeRoundingMode
{
- $value = ($value * $factor - self::HALF_ODD_EVEN_THRESHOLD) / $factor;
-
- return floor($value * $factor) / $factor;
+ return match ($this) {
+ RoundingMode::Up => NativeRoundingMode::AwayFromZero,
+ RoundingMode::Down => NativeRoundingMode::TowardsZero,
+ RoundingMode::Floor => NativeRoundingMode::NegativeInfinity,
+ RoundingMode::HalfUp => NativeRoundingMode::HalfAwayFromZero,
+ RoundingMode::Ceiling => NativeRoundingMode::PositiveInfinity,
+ RoundingMode::HalfOdd => NativeRoundingMode::HalfOdd,
+ RoundingMode::HalfDown => NativeRoundingMode::HalfTowardsZero,
+ RoundingMode::HalfEven => NativeRoundingMode::HalfEven
+ };
}
}
diff --git a/tests/BigDecimalTest.php b/tests/BigDecimalTest.php
deleted file mode 100644
index 701d90c..0000000
--- a/tests/BigDecimalTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-toString());
- }
-
- #[DataProvider('dataProviderForFromFloat')]
- public function testFromFloat(float $value): void
- {
- /** @Given a float value to create a BigDecimal instance */
- $actual = BigDecimal::fromFloat(value: $value);
-
- /** @Then the created object should be an instance of both BigNumber and BigDecimal */
- self::assertInstanceOf(BigNumber::class, $actual);
- self::assertInstanceOf(BigDecimal::class, $actual);
-
- /** @And the scale and value should be correctly initialized */
- self::assertSame($value, $actual->toFloat());
- }
-
- public static function dataProviderForFromString(): array
- {
- return [
- 'Zero value' => ['value' => '0'],
- 'Decimal string' => ['value' => '0.3333333333333333333333'],
- 'Positive integer' => ['value' => '1'],
- 'Negative integer' => ['value' => '-1']
- ];
- }
-
- public static function dataProviderForFromFloat(): array
- {
- return [
- 'Zero value' => ['value' => 0.0],
- 'Positive float' => ['value' => 0.3333333333333],
- 'Positive integer' => ['value' => 1.0],
- 'Negative integer' => ['value' => -1.0]
- ];
- }
-}
diff --git a/tests/BigNumberTest.php b/tests/BigNumberTest.php
deleted file mode 100644
index be4ba55..0000000
--- a/tests/BigNumberTest.php
+++ /dev/null
@@ -1,589 +0,0 @@
-absolute();
-
- /** @Then the result should be an instance of BigNumber */
- self::assertInstanceOf(BigNumber::class, $actual);
-
- /** @And the value should be the absolute value of the negative number */
- self::assertSame(abs($negativeValue), $actual->toFloat());
- self::assertSame(sprintf('%s', abs($negativeValue)), $actual->toString());
- }
-
- #[DataProvider('providerForTestAdd')]
- public function testAdd(int $scale, mixed $value, mixed $other, array $expected): void
- {
- /** @Given two BigNumber instances to be added */
- $augend = LargeNumber::fromString(value: $value);
- $addend = LargeNumber::fromString(value: $other);
-
- /** @When adding the two BigNumber instances */
- $actual = $augend->add(addend: $addend);
-
- /** @Then the result should have the correct scale and values */
- self::assertSame($scale, $actual->getScale());
- self::assertSame($expected['float'], $actual->toFloat());
- self::assertSame($expected['string'], $actual->toString());
- }
-
- #[DataProvider('providerForTestSubtract')]
- public function testSubtract(int $scale, mixed $value, mixed $other, array $expected): void
- {
- /** @Given two BigNumber instances to be subtracted */
- $minuend = LargeNumber::fromString(value: $value);
- $subtrahend = LargeNumber::fromString(value: $other);
-
- /** @When subtracting the second BigNumber from the first */
- $actual = $minuend->subtract(subtrahend: $subtrahend);
-
- /** @Then the result should have the correct scale and values */
- self::assertSame($scale, $actual->getScale());
- self::assertSame($expected['float'], $actual->toFloat());
- self::assertSame($expected['string'], $actual->toString());
- }
-
- #[DataProvider('providerForTestMultiply')]
- public function testMultiply(int $scale, mixed $value, mixed $other, array $expected): void
- {
- /** @Given two BigNumber instances to be multiplied */
- $multiplicand = LargeNumber::fromString(value: $value);
- $multiplier = LargeNumber::fromString(value: $other);
-
- /** @When multiplying the two BigNumber instances */
- $actual = $multiplicand->multiply(multiplier: $multiplier);
-
- /** @Then the result should have the correct scale and values */
- self::assertSame($scale, $actual->getScale());
- self::assertSame($expected['float'], $actual->toFloat());
- self::assertSame($expected['string'], $actual->toString());
- }
-
- #[DataProvider('providerForTestDivide')]
- public function testDivide(int $scale, mixed $value, mixed $other, array $expected): void
- {
- /** @Given a BigNumber instance to be divided by another BigNumber */
- $dividend = LargeNumber::fromString(value: $value);
- $divisor = LargeNumber::fromString(value: $other);
-
- /** @When dividing the first BigNumber by the second */
- $actual = $dividend->divide(divisor: $divisor);
-
- /** @Then the result should have the correct scale and values */
- self::assertSame($scale, $actual->getScale());
- self::assertSame($expected['float'], $actual->toFloat());
- self::assertSame($expected['string'], $actual->toString());
- }
-
- #[DataProvider('providerForTestDivisionByZero')]
- public function testDivisionByZero(mixed $value, mixed $other): void
- {
- /** @Given a BigNumber instance to be divided by zero */
- $template = 'Cannot divide <%.2f> by <%.2f>.';
-
- /** @Then an exception DivisionByZero should be thrown with the correct message */
- $this->expectException(DivisionByZero::class);
- $this->expectExceptionMessage(sprintf($template, $value, $other));
-
- /** @When attempting to divide the BigNumber by zero */
- $dividend = LargeNumber::fromFloat(value: $value);
- $divisor = LargeNumber::fromFloat(value: $other);
-
- $dividend->divide(divisor: $divisor);
- }
-
- #[DataProvider('providerForTestWithRounding')]
- public function testWithRounding(RoundingMode $mode, int $scale, mixed $value, array $expected): void
- {
- /** @Given a BigNumber instance with specified rounding mode */
- $number = LargeNumber::fromFloat(value: $value, scale: $scale);
-
- /** @When rounding the BigNumber */
- $actual = $number->withRounding(mode: $mode);
-
- /** @Then the result should match the expected values */
- self::assertSame($scale, $actual->getScale());
- self::assertSame($expected['float'], $actual->toFloat());
- self::assertSame($expected['string'], $actual->toString());
- }
-
- #[DataProvider('providerForTestWithScale')]
- public function testWithScale(mixed $value, int $scale, array $expected): void
- {
- /** @Given a BigNumber instance */
- $number = LargeNumber::fromFloat(value: $value);
-
- /** @When applying a new scale to the BigNumber */
- $actual = $number->withScale(scale: $scale);
-
- /** @Then the result should have the correct adjusted scale and values */
- self::assertSame($scale, $actual->getScale());
- self::assertSame($expected['float'], $actual->toFloat());
- self::assertSame($expected['string'], $actual->toString());
- }
-
- #[DataProvider('providerForTestIsZero')]
- public function testIsZero(mixed $value, bool $expected): void
- {
- /** @Given a BigNumber instance */
- $number = LargeNumber::fromFloat(value: $value);
-
- /** @When checking if the BigNumber is zero */
- $actual = $number->isZero();
-
- /** @Then the result should indicate if it is zero or not */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('providerForTestIsNegative')]
- public function testIsNegative(mixed $value, bool $expected): void
- {
- /** @Given a BigNumber instance */
- $number = LargeNumber::fromFloat(value: $value);
-
- /** @When checking if the BigNumber is negative */
- $actual = $number->isNegative();
-
- /** @Then the result should indicate if it is negative or not */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('providerForTestIsPositive')]
- public function testIsPositive(mixed $value, bool $expected): void
- {
- /** @Given a BigNumber instance */
- $number = LargeNumber::fromFloat(value: $value);
-
- /** @When checking if the BigNumber is positive */
- $actual = $number->isPositive();
-
- /** @Then the result should indicate if it is positive or not */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('providerForTestIsNegativeOrZero')]
- public function testIsNegativeOrZero(mixed $value, bool $expected): void
- {
- /** @Given a BigNumber instance */
- $number = LargeNumber::fromFloat(value: $value);
-
- /** @When checking if the BigNumber is negative or zero */
- $actual = $number->isNegativeOrZero();
-
- /** @Then the result should indicate if it is negative or zero */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('providerForTestIsPositiveOrZero')]
- public function testIsPositiveOrZero(mixed $value, bool $expected): void
- {
- /** @Given a BigNumber instance */
- $number = LargeNumber::fromFloat(value: $value);
-
- /** @When checking if the BigNumber is positive or zero */
- $actual = $number->isPositiveOrZero();
-
- /** @Then the result should indicate if it is positive or zero */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('providerForTestIsLessThan')]
- public function testIsLessThan(BigNumber $value, BigNumber $other, bool $expected): void
- {
- /** @Given two BigNumber instances */
- /** @When checking if the first BigNumber is less than the second */
- $actual = $value->isLessThan(other: $other);
-
- /** @Then the result should indicate if it is less than */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('providerForTestIsGreaterThan')]
- public function testIsGreaterThan(BigNumber $value, BigNumber $other, bool $expected): void
- {
- /** @Given two BigNumber instances */
- /** @When checking if the first BigNumber is greater than the second */
- $actual = $value->isGreaterThan(other: $other);
-
- /** @Then the result should indicate if it is greater than */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('providerForTestIsLessThanOrEqual')]
- public function testIsLessThanOrEqual(BigNumber $value, BigNumber $other, bool $expected): void
- {
- /** @Given two BigNumber instances */
- /** @When checking if the first BigNumber is less than or equal to the second */
- $actual = $value->isLessThanOrEqual(other: $other);
-
- /** @Then the result should indicate if it is less than or equal to */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('providerForTestIsGreaterThanOrEqual')]
- public function testIsGreaterThanOrEqual(BigNumber $value, BigNumber $other, bool $expected): void
- {
- /** @Given two BigNumber instances */
- /** @When checking if the first BigNumber is greater than or equal to the second */
- $actual = $value->isGreaterThanOrEqual(other: $other);
-
- /** @Then the result should indicate if it is greater than or equal to */
- self::assertSame($expected, $actual);
- }
-
- public static function providerForTestAdd(): array
- {
- return [
- 'Adding integers' => [
- 'scale' => 0,
- 'value' => '1',
- 'other' => '1',
- 'expected' => ['float' => 2.0, 'string' => '2']
- ],
- 'Adding with scale' => [
- 'scale' => 3,
- 'value' => '1002.771',
- 'other' => '123',
- 'expected' => ['float' => 1125.771, 'string' => '1125.771']
- ],
- 'Adding positives and negatives' => [
- 'scale' => 0,
- 'value' => '123',
- 'other' => '-999',
- 'expected' => ['float' => -876.0, 'string' => '-876']
- ],
- 'Adding large numbers with decimal' => [
- 'scale' => 4,
- 'value' => '-4565.9999',
- 'other' => '999999999.04',
- 'expected' => ['float' => 999995433.0401, 'string' => '999995433.0401']
- ]
- ];
- }
-
- public static function providerForTestSubtract(): array
- {
- return [
- 'Simple subtraction' => [
- 'scale' => 2,
- 'value' => '10.22',
- 'other' => '5.11',
- 'expected' => ['float' => 5.11, 'string' => '5.11']
- ],
- 'Subtracting negatives' => [
- 'scale' => 3,
- 'value' => '-10.099',
- 'other' => '-10.095',
- 'expected' => ['float' => -0.004, 'string' => '-0.004']
- ],
- 'Resulting in negative' => [
- 'scale' => 0,
- 'value' => '11',
- 'other' => '12',
- 'expected' => ['float' => -1.0, 'string' => '-1']
- ],
- 'Subtraction with scale' => [
- 'scale' => 4,
- 'value' => '12.9999',
- 'other' => '6.3333',
- 'expected' => ['float' => 6.6666, 'string' => '6.6666']
- ]
- ];
- }
-
- public static function providerForTestMultiply(): array
- {
- return [
- 'Basic multiplication' => [
- 'scale' => 0,
- 'value' => '2',
- 'other' => '2',
- 'expected' => ['float' => 4.0, 'string' => '4']
- ],
- 'Multiplying negatives' => [
- 'scale' => 4,
- 'value' => '-2.11',
- 'other' => '55.33',
- 'expected' => ['float' => -116.7463, 'string' => '-116.7463']
- ],
- 'Multiplying large numbers' => [
- 'scale' => 2,
- 'value' => '123.22',
- 'other' => '999',
- 'expected' => ['float' => 123096.78, 'string' => '123096.78']
- ],
- 'Multiplication with decimal' => [
- 'scale' => 1,
- 'value' => '123',
- 'other' => '0.1',
- 'expected' => ['float' => 12.3, 'string' => '12.3']
- ]
- ];
- }
-
- public static function providerForTestDivide(): array
- {
- return [
- 'Large division' => [
- 'scale' => 16,
- 'value' => '1.234',
- 'other' => '123.456',
- 'expected' => ['float' => 0.0099954639709694, 'string' => '0.0099954639709694']
- ],
- 'Division with small scale' => [
- 'scale' => 5,
- 'value' => '1.234',
- 'other' => '100.00',
- 'expected' => ['float' => 0.01234, 'string' => '0.01234']
- ],
- 'Division resulting in zero' => [
- 'scale' => 0,
- 'value' => '0.00',
- 'other' => '8',
- 'expected' => ['float' => 0.0, 'string' => '0']
- ],
- 'Division resulting in negative' => [
- 'scale' => 0,
- 'value' => '-7',
- 'other' => '0.2',
- 'expected' => ['float' => -35.0, 'string' => '-35']
- ]
- ];
- }
-
- public static function providerForTestDivisionByZero(): array
- {
- return [
- 'Division of zero by zero' => ['value' => 0, 'other' => 0],
- 'Division of positive by zero' => ['value' => 20, 'other' => 0],
- 'Division of decimal zero by zero' => ['value' => 0.00, 'other' => 0.00]
- ];
- }
-
- public static function providerForTestWithRounding(): array
- {
- return [
- 'Half up rounding' => [
- 'mode' => RoundingMode::HALF_UP,
- 'scale' => 2,
- 'value' => 0.9950,
- 'expected' => ['float' => 1.0, 'string' => '1']
- ],
- 'Half odd rounding' => [
- 'mode' => RoundingMode::HALF_ODD,
- 'scale' => 2,
- 'value' => 0.9950,
- 'expected' => ['float' => 0.99, 'string' => '0.99']
- ],
- 'Half down rounding' => [
- 'mode' => RoundingMode::HALF_DOWN,
- 'scale' => 2,
- 'value' => 0.9950,
- 'expected' => ['float' => 0.99, 'string' => '0.99']
- ],
- 'Half even rounding' => [
- 'mode' => RoundingMode::HALF_EVEN,
- 'scale' => 2,
- 'value' => 0.9950,
- 'expected' => ['float' => 1.0, 'string' => '1']
- ]
- ];
- }
-
- public static function providerForTestWithScale(): array
- {
- return [
- 'Zero scale with no decimals' => [
- 'value' => 0,
- 'scale' => 0,
- 'expected' => ['float' => 0.0, 'string' => '0']
- ],
- 'Zero scale with one decimal place' => [
- 'value' => 0,
- 'scale' => 1,
- 'expected' => ['float' => 0.0, 'string' => '0.0']
- ],
- 'Zero scale with two decimal places' => [
- 'value' => 0,
- 'scale' => 2,
- 'expected' => ['float' => 0.00, 'string' => '0.00']
- ],
- 'Zero scale with three decimal places' => [
- 'value' => 0,
- 'scale' => 3,
- 'expected' => ['float' => 0.000, 'string' => '0.000']
- ],
- 'Negative large value rounded to one decimal' => [
- 'value' => -553.99999,
- 'scale' => 1,
- 'expected' => ['float' => -553.9, 'string' => '-553.9']
- ],
- 'Large positive number rounded to two decimals' => [
- 'value' => 999999.999,
- 'scale' => 2,
- 'expected' => ['float' => 999999.99, 'string' => '999999.99']
- ],
- 'Small negative value rounded to four decimals' => [
- 'value' => -0.12345,
- 'scale' => 4,
- 'expected' => ['float' => -0.1234, 'string' => '-0.1234']
- ],
- 'Decimal value with precision reduction to two' => [
- 'value' => 123.4567,
- 'scale' => 2,
- 'expected' => ['float' => 123.45, 'string' => '123.45']
- ],
- 'Positive value with precision reduction to three' => [
- 'value' => 10.5555,
- 'scale' => 3,
- 'expected' => ['float' => 10.555, 'string' => '10.555']
- ]
- ];
- }
-
- public static function providerForTestIsZero(): array
- {
- return [
- 'Exact zero float' => ['value' => 0.0, 'expected' => true],
- 'NonZero negative' => ['value' => -1, 'expected' => false],
- 'Zero with decimals' => ['value' => 0.0000000000, 'expected' => true]
- ];
- }
-
- public static function providerForTestIsNegative(): array
- {
- return [
- 'NonNegative zero' => ['value' => 0, 'expected' => false],
- 'Large negative float' => ['value' => -45.9999, 'expected' => true],
- 'Small negative float' => ['value' => -0.1, 'expected' => true]
- ];
- }
-
- public static function providerForTestIsPositive(): array
- {
- return [
- 'Negative one' => ['value' => -1, 'expected' => false],
- 'Positive integer' => ['value' => 1, 'expected' => true],
- 'Zero is not positive' => ['value' => 0, 'expected' => false]
- ];
- }
-
- public static function providerForTestIsNegativeOrZero(): array
- {
- return [
- 'Zero' => ['value' => 0, 'expected' => true],
- 'Positive integer' => ['value' => 1, 'expected' => false],
- 'Negative integer' => ['value' => -1, 'expected' => true]
- ];
- }
-
- public static function providerForTestIsPositiveOrZero(): array
- {
- return [
- 'Zero' => ['value' => 0, 'expected' => true],
- 'Negative integer' => ['value' => -1, 'expected' => false],
- 'Positive integer' => ['value' => 1, 'expected' => true]
- ];
- }
-
- public static function providerForTestIsLessThan(): array
- {
- return [
- 'Value equal to other' => [
- 'value' => LargeNumber::fromFloat(value: 1),
- 'other' => LargeNumber::fromFloat(value: 1),
- 'expected' => false
- ],
- 'Value less than other with decimals' => [
- 'value' => LargeNumber::fromFloat(value: 45.333, scale: 3),
- 'other' => LargeNumber::fromFloat(value: 45.334, scale: 3),
- 'expected' => true
- ],
- 'Negative value less than other negative' => [
- 'value' => LargeNumber::fromString(value: '-51'),
- 'other' => LargeNumber::fromString(value: '-11'),
- 'expected' => true
- ]
- ];
- }
-
- public static function providerForTestIsGreaterThan(): array
- {
- return [
- 'Equal values' => [
- 'value' => LargeNumber::fromFloat(value: 1),
- 'other' => LargeNumber::fromFloat(value: 1),
- 'expected' => false
- ],
- 'Value greater than other' => [
- 'value' => LargeNumber::fromFloat(value: 12.12),
- 'other' => LargeNumber::fromFloat(value: 12.11),
- 'expected' => true
- ],
- 'Negative less than positive' => [
- 'value' => LargeNumber::fromString(value: '-1.2222'),
- 'other' => LargeNumber::fromString(value: '1'),
- 'expected' => false
- ]
- ];
- }
-
- public static function providerForTestIsLessThanOrEqual(): array
- {
- return [
- 'Values are equal' => [
- 'value' => LargeNumber::fromString(value: '88.664'),
- 'other' => LargeNumber::fromString(value: '88.664'),
- 'expected' => true
- ],
- 'Equal integer values' => [
- 'value' => LargeNumber::fromFloat(value: 1),
- 'other' => LargeNumber::fromFloat(value: 1),
- 'expected' => true
- ],
- 'Positive greater than negative' => [
- 'value' => LargeNumber::fromString(value: '12'),
- 'other' => LargeNumber::fromString(value: '-90.95'),
- 'expected' => false
- ]
- ];
- }
-
- public static function providerForTestIsGreaterThanOrEqual(): array
- {
- return [
- 'Greater than other' => [
- 'value' => LargeNumber::fromString(value: '45'),
- 'other' => LargeNumber::fromString(value: '45.01'),
- 'expected' => false
- ],
- 'Equal integer values' => [
- 'value' => LargeNumber::fromFloat(value: 1),
- 'other' => LargeNumber::fromFloat(value: 1),
- 'expected' => true
- ],
- 'Very large values equal' => [
- 'value' => LargeNumber::fromFloat(value: 99.999999999999999999),
- 'other' => LargeNumber::fromFloat(value: 99.99999999999999999),
- 'expected' => true
- ]
- ];
- }
-}
diff --git a/tests/Internal/NumberTest.php b/tests/Internal/NumberTest.php
deleted file mode 100644
index da0d3b3..0000000
--- a/tests/Internal/NumberTest.php
+++ /dev/null
@@ -1,64 +0,0 @@
- is not a valid number.';
-
- /** @Then an InvalidNumber exception should be thrown */
- $this->expectException(InvalidNumber::class);
- $this->expectExceptionMessage(sprintf($template, $value));
-
- /** @When attempting to create a Number instance with the invalid value */
- Number::from(value: $value);
- }
-
- public function testInvalidNumberWhenValueIsNaN(): void
- {
- /** @Given a Not a Number (NaN) value */
- $value = NAN;
- $template = 'The value is not a valid number.';
-
- /** @Then an InvalidNumber exception should be thrown */
- $this->expectException(InvalidNumber::class);
- $this->expectExceptionMessage($template);
-
- /** @When attempting to create a Number instance with the NaN value */
- Number::from(value: $value);
- }
-
- public static function invalidNumberDataProvider(): array
- {
- return [
- 'Single dot' => ['value' => '.'],
- 'Empty string' => ['value' => ''],
- 'String "null"' => ['value' => 'null'],
- 'String "true"' => ['value' => 'true'],
- 'String "false"' => ['value' => 'false'],
- 'Positive infinity' => ['value' => INF],
- 'Negative infinity' => ['value' => -INF],
- 'Zero followed by x' => ['value' => '0x'],
- 'Invalid character x' => ['value' => 'x'],
- 'Double leading dots' => ['value' => '..0'],
- 'Single negative sign' => ['value' => '-'],
- 'Single positive sign' => ['value' => '+'],
- 'Negative sign with x' => ['value' => '-x'],
- 'Positive sign with x' => ['value' => '+x'],
- 'Zero followed by dash' => ['value' => '0-'],
- 'Zero followed by plus' => ['value' => '0+'],
- 'Multiple dots in value' => ['value' => '.0.'],
- 'Leading space with zero' => ['value' => ' 0']
- ];
- }
-}
diff --git a/tests/Internal/Operations/Adapters/Scales/ScalesTest.php b/tests/Internal/Operations/Adapters/Scales/ScalesTest.php
deleted file mode 100644
index a5d0459..0000000
--- a/tests/Internal/Operations/Adapters/Scales/ScalesTest.php
+++ /dev/null
@@ -1,189 +0,0 @@
-applyScale();
-
- /** @Then the scale value should match the expected value */
- self::assertEquals($expected, $actual->value);
- }
-
- #[DataProvider('subtractionDataProvider')]
- public function testSubtraction(BigNumber $minuend, BigNumber $subtrahend, int $expected): void
- {
- /** @Given two BigNumber instances to be subtracted */
- $subtraction = new Subtraction(minuend: $minuend, subtrahend: $subtrahend);
-
- /** @When applying the scale to the result of the subtraction */
- $actual = $subtraction->applyScale();
-
- /** @Then the scale value should match the expected value */
- self::assertEquals($expected, $actual->value);
- }
-
- #[DataProvider('multiplicationDataProvider')]
- public function testMultiplication(BigNumber $multiplier, BigNumber $multiplicand, int $expected): void
- {
- /** @Given two BigNumber instances to be multiplied */
- $multiplication = new Multiplication(multiplicand: $multiplicand, multiplier: $multiplier);
-
- /** @When applying the scale to the result of the multiplication */
- $actual = $multiplication->applyScale();
-
- /** @Then the scale value should match the expected value */
- self::assertEquals($expected, $actual->value);
- }
-
- #[DataProvider('divisionDataProvider')]
- public function testDivision(BigNumber $dividend, BigNumber $divisor, int $expected): void
- {
- /** @Given two BigNumber instances to be divided */
- $division = new Division(dividend: $dividend, divisor: $divisor);
-
- /** @When applying the scale to the result of the division */
- $actual = $division->applyScale();
-
- /** @Then the scale value should match the expected value */
- self::assertEquals($expected, $actual->value);
- }
-
- public static function additionDataProvider(): array
- {
- return [
- 'Adding integers' => [
- 'addend' => BigDecimal::fromFloat(value: 1),
- 'augend' => BigDecimal::fromFloat(value: 1),
- 'expected' => 0
- ],
- 'Adding large decimals' => [
- 'addend' => BigDecimal::fromFloat(value: 1.001),
- 'augend' => BigDecimal::fromFloat(value: 1.0001),
- 'expected' => 4
- ],
- 'Adding integer and decimal' => [
- 'addend' => BigDecimal::fromFloat(value: 1),
- 'augend' => BigDecimal::fromFloat(value: 1.1),
- 'expected' => 1
- ],
- 'Adding decimals with specific scale' => [
- 'addend' => BigDecimal::fromFloat(value: 1.001, scale: 3),
- 'augend' => BigDecimal::fromFloat(value: 1.0001),
- 'expected' => 3
- ],
- 'Adding decimals with different scales' => [
- 'addend' => BigDecimal::fromFloat(value: 1.01),
- 'augend' => BigDecimal::fromFloat(value: 1.1),
- 'expected' => 2
- ]
- ];
- }
-
- public static function subtractionDataProvider(): array
- {
- return [
- 'Subtracting integers' => [
- 'minuend' => BigDecimal::fromFloat(value: 1),
- 'subtrahend' => BigDecimal::fromFloat(value: 1),
- 'expected' => 0
- ],
- 'Subtracting large decimals' => [
- 'minuend' => BigDecimal::fromFloat(value: 1.001),
- 'subtrahend' => BigDecimal::fromFloat(value: 1.0001),
- 'expected' => 4
- ],
- 'Subtracting integer and decimal' => [
- 'minuend' => BigDecimal::fromFloat(value: 1),
- 'subtrahend' => BigDecimal::fromFloat(value: 1.1),
- 'expected' => 1
- ],
- 'Subtracting decimals with specific scale' => [
- 'minuend' => BigDecimal::fromFloat(value: 1.001, scale: 3),
- 'subtrahend' => BigDecimal::fromFloat(value: 1.0001),
- 'expected' => 3
- ],
- 'Subtracting decimals with different scales' => [
- 'minuend' => BigDecimal::fromFloat(value: 1.01),
- 'subtrahend' => BigDecimal::fromFloat(value: 1.1),
- 'expected' => 2
- ]
- ];
- }
-
- public static function multiplicationDataProvider(): array
- {
- return [
- 'Multiplying integers' => [
- 'multiplier' => BigDecimal::fromFloat(value: 1),
- 'multiplicand' => BigDecimal::fromFloat(value: 1),
- 'expected' => 0
- ],
- 'Multiplying large decimals' => [
- 'multiplier' => BigDecimal::fromFloat(value: 1.001),
- 'multiplicand' => BigDecimal::fromFloat(value: 1.0001),
- 'expected' => 4
- ],
- 'Multiplying integer and decimal' => [
- 'multiplier' => BigDecimal::fromFloat(value: 1),
- 'multiplicand' => BigDecimal::fromFloat(value: 1.1),
- 'expected' => 1
- ],
- 'Multiplying decimals with specific scale' => [
- 'multiplier' => BigDecimal::fromFloat(value: 1.001, scale: 3),
- 'multiplicand' => BigDecimal::fromFloat(value: 1.0001),
- 'expected' => 3
- ],
- 'Multiplying decimals with different scales' => [
- 'multiplier' => BigDecimal::fromFloat(value: 1.01),
- 'multiplicand' => BigDecimal::fromFloat(value: 1.1),
- 'expected' => 2
- ],
- ];
- }
-
- public static function divisionDataProvider(): array
- {
- return [
- 'Dividing integers' => [
- 'dividend' => BigDecimal::fromFloat(value: 1),
- 'divisor' => BigDecimal::fromFloat(value: 1),
- 'expected' => 0
- ],
- 'Dividing large decimals' => [
- 'dividend' => BigDecimal::fromFloat(value: 1.001),
- 'divisor' => BigDecimal::fromFloat(value: 1.0001),
- 'expected' => 12
- ],
- 'Dividing integer and decimal' => [
- 'dividend' => BigDecimal::fromFloat(value: 1),
- 'divisor' => BigDecimal::fromFloat(value: 1.1),
- 'expected' => 14
- ],
- 'Dividing decimals with specific scale' => [
- 'dividend' => BigDecimal::fromFloat(value: 1.001, scale: 3),
- 'divisor' => BigDecimal::fromFloat(value: 1.0001),
- 'expected' => 3
- ],
- 'Dividing decimals with different scales' => [
- 'dividend' => BigDecimal::fromFloat(value: 1.01),
- 'divisor' => BigDecimal::fromFloat(value: 1.1),
- 'expected' => 14
- ]
- ];
- }
-}
diff --git a/tests/Internal/Operations/MathOperationsFactoryTest.php b/tests/Internal/Operations/MathOperationsFactoryTest.php
deleted file mode 100644
index f375a8f..0000000
--- a/tests/Internal/Operations/MathOperationsFactoryTest.php
+++ /dev/null
@@ -1,22 +0,0 @@
- extensions.';
-
- $this->expectException(MathOperationsNotAvailable::class);
- $this->expectExceptionMessage(sprintf($template, 'bcmath'));
-
- new MathOperationsFactory(extension: new ExtensionAdapterMock())->build();
- }
-}
diff --git a/tests/Internal/ScaleTest.php b/tests/Internal/ScaleTest.php
deleted file mode 100644
index e6691df..0000000
--- a/tests/Internal/ScaleTest.php
+++ /dev/null
@@ -1,56 +0,0 @@
-value;
-
- /** @Then the scale value should match the expected value */
- self::assertEquals($value, $actual);
- }
-
- #[DataProvider('invalidScaleDataProvider')]
- public function testInvalidScale(int $value): void
- {
- /** @Given an invalid scale value */
- $template = 'Scale value <%s> is invalid. The value must be between <%s> and <%s>.';
-
- /** @Then an InvalidScale exception should be thrown */
- $this->expectException(InvalidScale::class);
- $this->expectExceptionMessage(sprintf($template, $value, 0, 2147483647));
-
- /** @When attempting to create a Scale with the invalid value */
- Scale::from(value: $value);
- }
-
- public static function validScaleDataProvider(): array
- {
- return [
- 'Minimum valid scale' => ['value' => 0],
- 'Maximum valid scale' => ['value' => 2147483647]
- ];
- }
-
- public static function invalidScaleDataProvider(): array
- {
- return [
- 'PHP integer maximum' => ['value' => PHP_INT_MAX],
- 'PHP integer minimum' => ['value' => PHP_INT_MIN],
- 'Exceeding maximum scale' => ['value' => 2147483648]
- ];
- }
-}
diff --git a/tests/Mocks/ExtensionAdapterMock.php b/tests/Mocks/ExtensionAdapterMock.php
deleted file mode 100644
index 4bdf7ad..0000000
--- a/tests/Mocks/ExtensionAdapterMock.php
+++ /dev/null
@@ -1,15 +0,0 @@
-toFloat());
-
- /** @Then the toString method should return the correct string representation */
- self::assertSame(sprintf('-%s', abs($value)), $negativeBigDecimal->toString());
- }
-
- #[DataProvider('dataProviderForTestNonNegativeValue')]
- public function testNonNegativeValue(mixed $value): void
- {
- /** @Given a non-negative value */
- $template = 'Value <%s> is not valid. Must be a negative number less than zero.';
-
- /** @Then a NonNegativeValue exception should be thrown with the correct message */
- $this->expectException(NonNegativeValue::class);
- $this->expectExceptionMessage(sprintf($template, $value));
-
- /** @When attempting to create a NegativeBigDecimal with a non-negative value */
- NegativeBigDecimal::fromFloat(value: $value);
- }
-
- public static function dataProviderForTestNonNegativeValue(): array
- {
- return [
- 'Zero value' => ['value' => 0],
- 'Positive integer' => ['value' => 1]
- ];
- }
-}
diff --git a/tests/PositiveBigDecimalTest.php b/tests/PositiveBigDecimalTest.php
deleted file mode 100644
index a1c9ea3..0000000
--- a/tests/PositiveBigDecimalTest.php
+++ /dev/null
@@ -1,82 +0,0 @@
-toFloat());
- self::assertNull($actual->getScale());
- }
-
- public function testFromString(): void
- {
- /** @Given a positive string value */
- $value = '0.3333333333333333333333';
-
- /** @When creating a PositiveBigDecimal from the string */
- $actual = PositiveBigDecimal::fromString(value: $value);
-
- /** @Then the created object should be an instance of both BigNumber and PositiveBigDecimal */
- self::assertInstanceOf(BigNumber::class, $actual);
- self::assertInstanceOf(PositiveBigDecimal::class, $actual);
-
- /** @And the scale and value should be correctly initialized */
- self::assertSame($value, $actual->toString());
- self::assertNull($actual->getScale());
- }
-
- #[DataProvider('dataProviderForTestNonPositiveValue')]
- public function testNonPositiveValue(mixed $value): void
- {
- /** @Given a non-positive value */
- $template = 'Value <%s> is not valid. Must be a positive number greater than zero.';
-
- /** @Then a NonPositiveValue exception should be thrown with the correct message */
- $this->expectException(NonPositiveValue::class);
- $this->expectExceptionMessage(sprintf($template, $value));
-
- /** @When attempting to create a PositiveBigDecimal with a non-positive value */
- PositiveBigDecimal::fromFloat(value: $value);
- }
-
- public function testNonPositiveValueWithCustomClass(): void
- {
- /** @Given a non-positive value */
- $template = 'Value <%s> is not valid. Must be a positive number greater than zero.';
-
- /** @Then a NonPositiveValue exception should be thrown with the correct message */
- $this->expectException(NonPositiveValue::class);
- $this->expectExceptionMessage(sprintf($template, -1.00));
-
- /** @When attempting to create a CustomPositiveBigDecimal with a non-positive value */
- CustomPositiveBigDecimal::fromFloat(value: -1.00);
- }
-
- public static function dataProviderForTestNonPositiveValue(): array
- {
- return [
- 'Zero value' => ['value' => 0],
- 'Negative integer' => ['value' => -1]
- ];
- }
-}
diff --git a/tests/RoundingModeTest.php b/tests/RoundingModeTest.php
deleted file mode 100644
index b1f4252..0000000
--- a/tests/RoundingModeTest.php
+++ /dev/null
@@ -1,111 +0,0 @@
-round(bigNumber: $bigNumber);
-
- /** @Then the result should match the expected */
- self::assertSame($expected, $actual->value);
- }
-
- #[DataProvider('halfOddDataProvider')]
- public function testRoundHalfOdd(float $value, string $expected): void
- {
- /** @Given a value and HALF_ODD rounding mode */
- $bigNumber = BigDecimal::fromFloat(value: $value);
-
- /** @When rounding the value using HALF_ODD */
- $actual = RoundingMode::HALF_ODD->round(bigNumber: $bigNumber);
-
- /** @Then the result should match the expected */
- self::assertSame($expected, $actual->value);
- }
-
- #[DataProvider('halfDownDataProvider')]
- public function testRoundHalfDown(float $value, string $expected): void
- {
- /** @Given a value and HALF_DOWN rounding mode */
- $bigNumber = BigDecimal::fromFloat(value: $value);
-
- /** @When rounding the value using HALF_DOWN */
- $actual = RoundingMode::HALF_DOWN->round(bigNumber: $bigNumber);
-
- /** @Then the result should match the expected */
- self::assertSame($expected, $actual->value);
- }
-
- #[DataProvider('halfEvenDataProvider')]
- public function testRoundHalfEven(float $value, string $expected): void
- {
- /** @Given a value and HALF_EVEN rounding mode */
- $bigNumber = BigDecimal::fromFloat(value: $value);
-
- /** @When rounding the value using HALF_EVEN */
- $actual = RoundingMode::HALF_EVEN->round(bigNumber: $bigNumber);
-
- /** @Then the result should match the expected */
- self::assertSame($expected, $actual->value);
- }
-
- public static function halfUpDataProvider(): array
- {
- return [
- 'Half up, round 0.5 up to 1' => ['value' => 0.5, 'expected' => '1'],
- 'Half up, round 1.50 up to 2' => ['value' => 1.50, 'expected' => '2'],
- 'Half up, round 1.75 up to 2' => ['value' => 1.75, 'expected' => '2'],
- 'Half up, round 2.50 up to 3' => ['value' => 2.50, 'expected' => '3'],
- 'Half up, round 1.45 down to 1' => ['value' => 1.45, 'expected' => '1'],
- 'Half up, round -1.50 down to -2' => ['value' => -1.50, 'expected' => '-2']
- ];
- }
-
- public static function halfOddDataProvider(): array
- {
- return [
- 'Half odd, round 0.5 up to 1' => ['value' => 0.5, 'expected' => '1'],
- 'Half odd, round 2.55 up to 3' => ['value' => 2.55, 'expected' => '3'],
- 'Half odd, round 1.50 down to 1' => ['value' => 1.50, 'expected' => '1'],
- 'Half odd, round 1.25 down to 1' => ['value' => 1.25, 'expected' => '1'],
- 'Half odd, round 1.75 down to 1' => ['value' => 1.75, 'expected' => '1'],
- 'Half odd, round -1.50 down to -1' => ['value' => -1.50, 'expected' => '-1']
- ];
- }
-
- public static function halfDownDataProvider(): array
- {
- return [
- 'Half down, round 0.5 down to 0' => ['value' => 0.5, 'expected' => '0'],
- 'Half down, round 1.50 down to 1' => ['value' => 1.50, 'expected' => '1'],
- 'Half down, round 2.50 down to 2' => ['value' => 2.50, 'expected' => '2'],
- 'Half down, round 1.45 down to 0' => ['value' => 1.45, 'expected' => '0'],
- 'Half down, round 1.75 down to 1' => ['value' => 1.75, 'expected' => '1'],
- 'Half down, round -1.50 down to -2' => ['value' => -1.50, 'expected' => '-2']
- ];
- }
-
- public static function halfEvenDataProvider(): array
- {
- return [
- 'Half even, round 2.55 up to 3' => ['value' => 2.55, 'expected' => '3'],
- 'Half even, round 1.50 up to 2' => ['value' => 1.50, 'expected' => '2'],
- 'Half even, round 1.75 up to 2' => ['value' => 1.75, 'expected' => '2'],
- 'Half even, round 0.5 down to 0' => ['value' => 0.5, 'expected' => '0'],
- 'Half even, round 1.25 down to 1' => ['value' => 1.25, 'expected' => '1'],
- 'Half even, round -1.50 down to -2' => ['value' => -1.50, 'expected' => '-2']
- ];
- }
-}
diff --git a/tests/Unit/BigDecimalTest.php b/tests/Unit/BigDecimalTest.php
new file mode 100644
index 0000000..1426160
--- /dev/null
+++ b/tests/Unit/BigDecimalTest.php
@@ -0,0 +1,959 @@
+negated();
+
+ /** @Then the sign flips and the scale is untouched */
+ self::assertSame('-1.50', $actual->toString());
+ }
+
+ public function testAbsoluteThenKeepsTheScale(): void
+ {
+ /** @Given a negative decimal */
+ $amount = BigDecimal::of(value: '-1.50');
+
+ /** @When its magnitude is taken */
+ $actual = $amount->absolute();
+
+ /** @Then the sign is gone and the scale is untouched */
+ self::assertSame('1.50', $actual->toString());
+ }
+
+ public function testOneThenHoldsOneAtScaleZero(): void
+ {
+ /** @Given nothing but the factory */
+
+ /** @When one is created */
+ $actual = BigDecimal::one();
+
+ /** @Then it holds one with no fractional digits */
+ self::assertSame('1', $actual->toString());
+ }
+
+ public function testZeroThenHoldsZeroAtScaleZero(): void
+ {
+ /** @Given nothing but the factory */
+
+ /** @When zero is created */
+ $actual = BigDecimal::zero();
+
+ /** @Then it holds zero with no fractional digits */
+ self::assertSame('0', $actual->toString());
+ }
+
+ #[DataProvider('literalProvider')]
+ public function testOfThenReadsTheLiteralAndItsScale(string|int $value, string $expected, int $scale): void
+ {
+ /** @Given a decimal literal with the value and scale it stands for */
+
+ /** @When a decimal is created from it */
+ $actual = BigDecimal::of(value: $value);
+
+ /** @Then both the value and the scale are read exactly */
+ self::assertSame($expected, $actual->toString());
+ self::assertSame($scale, $actual->scale());
+ }
+
+ public function testJsonSerializeThenEmitsAJsonString(): void
+ {
+ /** @Given a decimal whose trailing zero carries meaning */
+ $amount = BigDecimal::of(value: '1.50');
+
+ /** @When it is encoded */
+ $actual = json_encode($amount);
+
+ /** @Then it is a JSON string so the trailing zero survives */
+ self::assertSame('"1.50"', $actual);
+ }
+
+ #[DataProvider('roundingProvider')]
+ public function testToScaleThenAppliesTheRoundingMode(
+ string $value,
+ int $scale,
+ RoundingMode $rounding,
+ string $expected
+ ): void {
+ /** @Given a decimal, a target scale, and a rounding mode */
+
+ /** @When it is taken to that scale */
+ $actual = BigDecimal::of(value: $value)->toScale(scale: $scale, rounding: $rounding);
+
+ /** @Then the discarded digits are resolved by the mode */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ public function testAllocateThenPartsSumBackToTheAmount(): void
+ {
+ /** @Given an amount that does not divide evenly */
+ $amount = BigDecimal::of(value: '100.00');
+
+ /** @And three equal weights */
+ $weights = BigDecimals::of('1', '1', '1');
+
+ /** @When the amount is allocated */
+ $actual = $amount->allocate(scale: 2, weights: $weights);
+
+ /** @Then the parts sum back to the amount with no unit lost */
+ self::assertSame('100.00', $actual->sum()->toString());
+ }
+
+ public function testDividedByThenReturnsAnExactFraction(): void
+ {
+ /** @Given an amount */
+ $amount = BigDecimal::of(value: '100.00');
+
+ /** @And a divisor whose quotient repeats forever */
+ $divisor = BigDecimal::of(value: '3');
+
+ /** @When it is divided */
+ $actual = $amount->dividedBy(divisor: $divisor);
+
+ /** @Then the exact fraction is returned instead of a rounded decimal */
+ self::assertSame('100/3', $actual->toString());
+ }
+
+ #[DataProvider('equalityProvider')]
+ public function testEqualsAndIsEqualToThenDisagreeOnScale(
+ string $value,
+ string $other,
+ bool $structural,
+ bool $arithmetic
+ ): void {
+ /** @Given two decimal literals with their structural and arithmetic verdicts */
+
+ /** @When both notions of equality are asked */
+ $actual = BigDecimal::of(value: $value);
+
+ /** @Then structural equality sees the scale and arithmetic equality does not */
+ self::assertSame($structural, $actual->equals(other: BigDecimal::of(value: $other)));
+ self::assertSame($arithmetic, $actual->isEqualTo(other: BigDecimal::of(value: $other)));
+ }
+
+ public function testToBigRationalThenReducesToLowestTerms(): void
+ {
+ /** @Given a decimal */
+ $amount = BigDecimal::of(value: '0.75');
+
+ /** @When it is converted to a fraction */
+ $actual = $amount->toBigRational();
+
+ /** @Then the fraction is in lowest terms */
+ self::assertSame('3/4', $actual->toString());
+ }
+
+ public function testUnscaledValueThenDropsTheDecimalPoint(): void
+ {
+ /** @Given a decimal with two fractional digits */
+ $amount = BigDecimal::of(value: '19.99');
+
+ /** @When its unscaled digits are taken */
+ $actual = $amount->unscaledValue();
+
+ /** @Then the decimal point is gone */
+ self::assertSame('1999', $actual->toString());
+ }
+
+ #[DataProvider('shortestRoundTripProvider')]
+ public function testFromFloatThenReadsTheShortestRoundTrip(float $value, string $expected): void
+ {
+ /** @Given a float and the shortest decimal that casts back to it */
+
+ /** @When a decimal is created from it */
+ $actual = BigDecimal::fromFloat(value: $value);
+
+ /** @Then the shortest decimal that round-trips is used */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('squareRootProvider')]
+ public function testSquareRootThenRoundsAtTheRequestedScale(
+ string $value,
+ int $scale,
+ RoundingMode $rounding,
+ string $expected
+ ): void {
+ /** @Given a radicand, a target scale, and a rounding mode */
+
+ /** @When its square root is taken */
+ $actual = BigDecimal::of(value: $value)->squareRoot(scale: $scale, rounding: $rounding);
+
+ /** @Then the root is correct at that scale */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ public function testHashCodeWhenScalesDifferThenHashesDiffer(): void
+ {
+ /** @Given a decimal at scale one */
+ $amount = BigDecimal::of(value: '1.0');
+
+ /** @And the same value at scale two */
+ $other = BigDecimal::of(value: '1.00');
+
+ /** @When both hashes are taken */
+ $actual = $amount->hashCode();
+
+ /** @Then they differ, because the hash agrees with structural equality */
+ self::assertNotSame($other->hashCode(), $actual);
+ }
+
+ public function testOfUnscaledValueThenPlacesTheDecimalPoint(): void
+ {
+ /** @Given unscaled digits */
+ $unscaled = BigInteger::of(value: 1999);
+
+ /** @When a decimal is built at scale two */
+ $actual = BigDecimal::ofUnscaledValue(scale: 2, unscaled: $unscaled);
+
+ /** @Then the point sits two digits from the right */
+ self::assertSame('19.99', $actual->toString());
+ }
+
+ #[DataProvider('powerProvider')]
+ public function testPowerThenMultipliesTheScaleByTheExponent(string $value, int $exponent, string $expected): void
+ {
+ /** @Given a decimal, an exponent, and the expected exact power */
+
+ /** @When it is raised to that power */
+ $actual = BigDecimal::of(value: $value)->power(exponent: $exponent);
+
+ /** @Then the result is exact and its scale is the scale times the exponent */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('arithmeticProvider')]
+ public function testArithmeticThenPropagatesScaleAsDocumented(
+ string $left,
+ string $right,
+ string $sum,
+ string $difference,
+ string $product
+ ): void {
+ /** @Given two decimal literals and the expected exact results */
+
+ /** @When the three exact operations run */
+ $actual = BigDecimal::of(value: $left);
+
+ /** @Then addition and subtraction keep the larger scale and multiplication sums them */
+ self::assertSame($sum, $actual->plus(addend: BigDecimal::of(value: $right))->toString());
+ self::assertSame($difference, $actual->minus(subtrahend: BigDecimal::of(value: $right))->toString());
+ self::assertSame($product, $actual->multipliedBy(multiplier: BigDecimal::of(value: $right))->toString());
+ }
+
+ public function testAllocateWhenAWeightIsZeroThenThatPartIsZero(): void
+ {
+ /** @Given an amount */
+ $amount = BigDecimal::of(value: '10.00');
+
+ /** @And a weight of zero beside a positive one */
+ $weights = BigDecimals::of('1', '0');
+
+ /** @When the amount is allocated */
+ $actual = $amount->allocate(scale: 2, weights: $weights);
+
+ /** @Then the zero weight receives nothing */
+ self::assertSame(
+ ['10.00', '0.00'],
+ array_map(static fn(BigDecimal $part): string => $part->toString(), $actual->all())
+ );
+ }
+
+ public function testDividedByWhenDivisorIsZeroThenDivisionByZero(): void
+ {
+ /** @Given a decimal */
+ $amount = BigDecimal::of(value: '1.00');
+
+ /** @Then a failure naming the dividend is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Cannot divide <1> by zero.');
+
+ /** @When it is divided by zero */
+ $amount->dividedBy(divisor: BigDecimal::zero());
+ }
+
+ public function testSquareRootWhenValueIsNegativeThenNegativeRoot(): void
+ {
+ /** @Given a negative decimal */
+ $amount = BigDecimal::of(value: '-1.00');
+
+ /** @Then a failure naming the decimal, not its unscaled digits, is raised */
+ $this->expectException(NegativeRoot::class);
+ $this->expectExceptionMessage('Cannot take the square root of the negative value <-1.00>.');
+
+ /** @When its square root is taken */
+ $amount->squareRoot(scale: 2, rounding: RoundingMode::HalfEven);
+ }
+
+ public function testToScaleWhenScaleIsNegativeThenScaleOutOfRange(): void
+ {
+ /** @Given a decimal */
+ $amount = BigDecimal::of(value: '1.00');
+
+ /** @Then a failure describing the scale bounds is raised */
+ $this->expectException(ScaleOutOfRange::class);
+ $this->expectExceptionMessage('Scale must be between 0 and 10000, got <-1>.');
+
+ /** @When it is taken to a negative scale */
+ $amount->toScale(scale: -1, rounding: RoundingMode::Down);
+ }
+
+ public function testAllocateWhenWeightsSumToZeroThenDivisionByZero(): void
+ {
+ /** @Given an amount */
+ $amount = BigDecimal::of(value: '10.00');
+
+ /** @And weights that all hold zero */
+ $weights = BigDecimals::of('0', '0');
+
+ /** @Then a failure describing the empty total is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Allocation weights must not sum to zero.');
+
+ /** @When the amount is allocated */
+ $amount->allocate(scale: 2, weights: $weights);
+ }
+
+ public function testPowerWhenScaleWouldOverflowThenScaleOutOfRange(): void
+ {
+ /** @Given a decimal carrying two fractional digits */
+ $amount = BigDecimal::of(value: '1.50');
+
+ /** @Then a failure naming the scale the power would need is raised */
+ $this->expectException(ScaleOutOfRange::class);
+ $this->expectExceptionMessage('Scale must be between 0 and 10000, got <4294967294>.');
+
+ /** @When it is raised to a power whose scale exceeds the supported range */
+ $amount->power(exponent: 2147483647);
+ }
+
+ public function testAllocateWhenAWeightIsNegativeThenNegativeWeight(): void
+ {
+ /** @Given an amount */
+ $amount = BigDecimal::of(value: '10.00');
+
+ /** @And a negative weight */
+ $weights = BigDecimals::of('-1', '1');
+
+ /** @Then a failure naming the rejected weight is raised */
+ $this->expectException(NegativeWeight::class);
+ $this->expectExceptionMessage('Allocation weights must not be negative, got <-1>.');
+
+ /** @When the amount is allocated */
+ $amount->allocate(scale: 2, weights: $weights);
+ }
+
+ #[DataProvider('malformedLiteralProvider')]
+ public function testOfWhenLiteralIsMalformedThenNumberNotWellFormed(string $value): void
+ {
+ /** @Given a literal that is not a number */
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When a decimal is created from it */
+ BigDecimal::of(value: $value);
+ }
+
+ public function testPowerWhenExponentIsNegativeThenNegativeExponent(): void
+ {
+ /** @Given a decimal */
+ $amount = BigDecimal::of(value: '1.5');
+
+ /** @Then a failure describing the negative exponent is raised */
+ $this->expectException(NegativeExponent::class);
+
+ /** @When it is raised to a negative power */
+ $amount->power(exponent: -1);
+ }
+
+ #[DataProvider('allocationProvider')]
+ public function testAllocateThenHandsLeftoversToTheLargestRemainders(
+ string $amount,
+ BigDecimals $weights,
+ array $expected
+ ): void {
+ /** @Given an amount, its weights, and the expected parts */
+
+ /** @When the amount is allocated at scale two */
+ $actual = BigDecimal::of(value: $amount);
+
+ /** @Then the parts match, in the order of the weights */
+ self::assertSame(
+ $expected,
+ array_map(
+ static fn(BigDecimal $part): string => $part->toString(),
+ $actual->allocate(scale: 2, weights: $weights)->all()
+ )
+ );
+ }
+
+ #[DataProvider('crossTypeProvider')]
+ public function testCompareToWhenTheOtherIsAnotherTypeThenStaysExact(
+ string $value,
+ Number $other,
+ int $expected
+ ): void {
+ /** @Given a decimal and a number of another type with the expected comparison */
+
+ /** @When they are compared */
+ $actual = BigDecimal::of(value: $value)->compareTo(other: $other);
+
+ /** @Then the comparison crosses the type boundary */
+ self::assertSame($expected, $actual);
+ }
+
+ #[DataProvider('partsProvider')]
+ public function testIntegralAndFractionalPartsThenReconstructTheValue(
+ string $value,
+ string $integral,
+ string $fractional
+ ): void {
+ /** @Given a decimal literal with its expected parts */
+
+ /** @When both parts are taken */
+ $actual = BigDecimal::of(value: $value);
+
+ /** @Then each part matches and the integral part truncates toward zero */
+ self::assertSame($integral, $actual->integralPart()->toString());
+ self::assertSame($fractional, $actual->fractionalPart()->toString());
+ }
+
+ #[DataProvider('trailingZeroProvider')]
+ public function testWithoutTrailingZerosThenReducesToTheSmallestScale(string $value, string $expected): void
+ {
+ /** @Given a decimal literal and its smallest representation */
+
+ /** @When trailing zeros are dropped */
+ $actual = BigDecimal::of(value: $value)->withoutTrailingZeros();
+
+ /** @Then the value is unchanged and the scale is minimal */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ public function testFractionalPartWhenValueIsNegativeThenCarriesTheSign(): void
+ {
+ /** @Given a negative decimal */
+ $amount = BigDecimal::of(value: '-19.99');
+
+ /** @When its fractional part is taken */
+ $actual = $amount->fractionalPart();
+
+ /** @Then the sign follows the value */
+ self::assertSame('-0.99', $actual->toString());
+ }
+
+ public function testFromFloatWhenValueIsInfiniteThenNumberNotWellFormed(): void
+ {
+ /** @Given an infinite float */
+ $value = INF;
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When a decimal is created from it */
+ BigDecimal::fromFloat(value: $value);
+ }
+
+ public function testFromFloatWhenValueIsNotFiniteThenNumberNotWellFormed(): void
+ {
+ /** @Given a float that is not a number */
+ $value = NAN;
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When a decimal is created from it */
+ BigDecimal::fromFloat(value: $value);
+ }
+
+ public function testFromFloatWhenAdditionLostPrecisionThenTheLossIsVisible(): void
+ {
+ /** @Given the classic float addition that is not exact */
+ $value = (0.1 + 0.2);
+
+ /** @When a decimal is created from it */
+ $actual = BigDecimal::fromFloat(value: $value);
+
+ /** @Then the library shows the loss rather than hiding it */
+ self::assertSame('0.30000000000000004', $actual->toString());
+ }
+
+ public function testToScaleExactWhenDigitsWouldBeLostThenInexactConversion(): void
+ {
+ /** @Given a decimal with three fractional digits */
+ $amount = BigDecimal::of(value: '1.234');
+
+ /** @Then a failure describing the discarded digits is raised */
+ $this->expectException(InexactConversion::class);
+ $this->expectExceptionMessage('Value <1.234> cannot be represented at scale <2> without discarding digits.');
+
+ /** @When it is taken to scale two without rounding */
+ $amount->toScaleExact(scale: 2);
+ }
+
+ public function testAllocateWhenAmountIsNegativeThenPartsSumBackToTheAmount(): void
+ {
+ /** @Given a negative amount that does not divide evenly */
+ $amount = BigDecimal::of(value: '-100.00');
+
+ /** @And three equal weights */
+ $weights = BigDecimals::of('1', '1', '1');
+
+ /** @When the amount is allocated */
+ $actual = $amount->allocate(scale: 2, weights: $weights);
+
+ /** @Then the parts still sum back to the amount */
+ self::assertSame('-100.00', $actual->sum()->toString());
+ }
+
+ public function testToFloatWhenValueExceedsTheFloatRangeThenIntegerOverflow(): void
+ {
+ /** @Given a decimal beyond the largest representable float */
+ $amount = BigDecimal::of(value: '1e400');
+
+ /** @Then a failure describing the overflow is raised */
+ $this->expectException(IntegerOverflow::class);
+
+ /** @When it is converted to a float */
+ $amount->toFloat();
+ }
+
+ public function testOfWhenExponentIsAtTheNegativeLimitThenTheScaleIsAccepted(): void
+ {
+ /** @Given a literal carrying the smallest exponent the library expands */
+ $value = '1e-10000';
+
+ /** @When a decimal is created from it */
+ $actual = BigDecimal::of(value: $value)->scale();
+
+ /** @Then it is accepted and the exponent became the scale */
+ self::assertSame(10000, $actual);
+ }
+
+ public function testAllocateWhenAmountDoesNotFitTheScaleThenInexactConversion(): void
+ {
+ /** @Given an amount with more digits than the allocation scale */
+ $amount = BigDecimal::of(value: '10.005');
+
+ /** @And a single weight */
+ $weights = BigDecimals::of('1');
+
+ /** @Then a failure describing the discarded digits is raised */
+ $this->expectException(InexactConversion::class);
+
+ /** @When the amount is allocated */
+ $amount->allocate(scale: 2, weights: $weights);
+ }
+
+ public function testOfWhenExponentIsAtTheSupportedLimitThenTheLiteralIsAccepted(): void
+ {
+ /** @Given a literal carrying the largest exponent the library expands */
+ $value = '1e10000';
+
+ /** @When a decimal is created from it */
+ $actual = BigDecimal::of(value: $value);
+
+ /** @Then it is accepted and carries every digit */
+ self::assertSame(10001, strlen($actual->toString()));
+ }
+
+ public function testToBigIntegerWhenFractionalPartIsNotZeroThenInexactConversion(): void
+ {
+ /** @Given a decimal with a non-zero fractional part */
+ $amount = BigDecimal::of(value: '1.5');
+
+ /** @Then a failure describing the lost fractional part is raised */
+ $this->expectException(InexactConversion::class);
+
+ /** @When it is converted to an integer */
+ $amount->toBigInteger();
+ }
+
+ public function testOfUnscaledValueWhenScaleIsTheLargestSupportedThenItIsAccepted(): void
+ {
+ /** @Given the largest scale the library supports */
+ $scale = 10000;
+
+ /** @When a decimal is built at that scale */
+ $actual = BigDecimal::ofUnscaledValue(scale: $scale, unscaled: BigInteger::one())->scale();
+
+ /** @Then the boundary itself is accepted */
+ self::assertSame($scale, $actual);
+ }
+
+ public function testPowerWhenExponentExceedsTheMagnitudeCeilingThenExponentOutOfRange(): void
+ {
+ /** @Given a decimal carrying a fractional digit */
+ $amount = BigDecimal::of(value: '1.5');
+
+ /** @Then a failure naming the rejected exponent is raised */
+ $this->expectException(ExponentOutOfRange::class);
+ $this->expectExceptionMessage('Exponent magnitude must not exceed 2147483647, got <2147483648>.');
+
+ /** @When it is raised to an exponent beyond the supported magnitude */
+ $amount->power(exponent: 2147483648);
+ }
+
+ public function testPowerWhenExponentWouldOverflowTheScaleProductThenExponentOutOfRange(): void
+ {
+ /** @Given a decimal carrying two fractional digits */
+ $amount = BigDecimal::of(value: '1.50');
+
+ /** @Then a failure naming the rejected exponent is raised */
+ $this->expectException(ExponentOutOfRange::class);
+ $this->expectExceptionMessage('Exponent magnitude must not exceed 2147483647, got <9223372036854775807>.');
+
+ /** @When it is raised to an exponent whose product with the scale would overflow */
+ $amount->power(exponent: PHP_INT_MAX);
+ }
+
+ public static function partsProvider(): array
+ {
+ return [
+ 'Positive' => ['value' => '19.99', 'integral' => '19', 'fractional' => '0.99'],
+ 'Negative' => ['value' => '-19.99', 'integral' => '-19', 'fractional' => '-0.99'],
+ 'No fraction' => ['value' => '19', 'integral' => '19', 'fractional' => '0'],
+ 'Below one' => ['value' => '0.99', 'integral' => '0', 'fractional' => '0.99']
+ ];
+ }
+
+ public static function powerProvider(): array
+ {
+ return [
+ 'Zero exponent' => ['value' => '1.5', 'exponent' => 0, 'expected' => '1'],
+ 'Identity' => ['value' => '1.50', 'exponent' => 1, 'expected' => '1.50'],
+ 'Cube' => ['value' => '1.5', 'exponent' => 3, 'expected' => '3.375'],
+ 'Negative base' => ['value' => '-1.5', 'exponent' => 2, 'expected' => '2.25']
+ ];
+ }
+
+ public static function literalProvider(): array
+ {
+ return [
+ 'Native integer' => ['value' => 42, 'expected' => '42', 'scale' => 0],
+ 'Trailing zero kept' => ['value' => '1.50', 'expected' => '1.50', 'scale' => 2],
+ 'Leading point' => ['value' => '.5', 'expected' => '0.5', 'scale' => 1],
+ 'Leading plus' => ['value' => '+5', 'expected' => '5', 'scale' => 0],
+ 'Leading zeros' => ['value' => '007.500', 'expected' => '7.500', 'scale' => 3],
+ 'Negative zero' => ['value' => '-0', 'expected' => '0', 'scale' => 0],
+ 'Zero with scale' => ['value' => '0.000', 'expected' => '0.000', 'scale' => 3],
+ 'Positive exponent' => ['value' => '1e3', 'expected' => '1000', 'scale' => 0],
+ 'Negative exponent' => ['value' => '1.5E-3', 'expected' => '0.0015', 'scale' => 4],
+ 'Trailing point' => ['value' => '5.', 'expected' => '5', 'scale' => 0]
+ ];
+ }
+
+ public static function equalityProvider(): array
+ {
+ return [
+ 'Same scale' => [
+ 'value' => '1.0',
+ 'other' => '1.0',
+ 'structural' => true,
+ 'arithmetic' => true
+ ],
+ 'Different scale' => [
+ 'value' => '1.0',
+ 'other' => '1.00',
+ 'structural' => false,
+ 'arithmetic' => true
+ ],
+ 'Different value' => [
+ 'value' => '1.0',
+ 'other' => '2.0',
+ 'structural' => false,
+ 'arithmetic' => false
+ ],
+ 'Past the float mantissa' => [
+ 'value' => '0.10000000000000000001',
+ 'other' => '0.10000000000000000002',
+ 'structural' => false,
+ 'arithmetic' => false
+ ]
+ ];
+ }
+
+ public static function roundingProvider(): array
+ {
+ return [
+ 'Truncating up' => [
+ 'value' => '1.23456',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '1.23'
+ ],
+ 'Half away' => [
+ 'value' => '0.995',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::HalfUp,
+ 'expected' => '1.00'
+ ],
+ 'Half toward' => [
+ 'value' => '0.995',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::HalfDown,
+ 'expected' => '0.99'
+ ],
+ 'Widening' => [
+ 'value' => '1.2',
+ 'scale' => 4,
+ 'rounding' => RoundingMode::Down,
+ 'expected' => '1.2000'
+ ],
+ 'Nothing to lose' => [
+ 'value' => '1.00',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::Up,
+ 'expected' => '1'
+ ],
+ 'Everything lost' => [
+ 'value' => '0.5',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::HalfUp,
+ 'expected' => '1'
+ ],
+ 'Discarded wider than the digits' => [
+ 'value' => '0.0045',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::HalfUp,
+ 'expected' => '0'
+ ],
+ 'Everything lost negative' => [
+ 'value' => '-0.5',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::HalfUp,
+ 'expected' => '-1'
+ ],
+ 'Beyond the float' => [
+ 'value' => '12345678901234567890.5',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '12345678901234567890'
+ ],
+ 'Exact already' => [
+ 'value' => '1.25',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::Up,
+ 'expected' => '1.25'
+ ],
+ 'Toward zero' => [
+ 'value' => '-0.4',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::Down,
+ 'expected' => '0'
+ ],
+ 'Above half' => [
+ 'value' => '1.226',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '1.23'
+ ],
+ 'Below half' => [
+ 'value' => '1.234',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '1.23'
+ ]
+ ];
+ }
+
+ public static function crossTypeProvider(): array
+ {
+ return [
+ 'Equal to a fraction' => ['value' => '1.50', 'other' => BigRational::of(value: '3/2'), 'expected' => 0],
+ 'Equal to an integer' => ['value' => '2.00', 'other' => BigInteger::of(value: 2), 'expected' => 0],
+ 'Less than an integer' => ['value' => '1.50', 'other' => BigInteger::of(value: 2), 'expected' => -1],
+ 'Greater than a fraction' => ['value' => '1.50', 'other' => BigRational::of(value: '1/3'), 'expected' => 1]
+ ];
+ }
+
+ public static function allocationProvider(): array
+ {
+ return [
+ 'Three equal parts' => [
+ 'amount' => '100.00',
+ 'weights' => BigDecimals::of('1', '1', '1'),
+ 'expected' => ['33.34', '33.33', '33.33']
+ ],
+ 'Uneven weights' => [
+ 'amount' => '0.05',
+ 'weights' => BigDecimals::of('3', '7'),
+ 'expected' => ['0.02', '0.03']
+ ],
+ 'Fractional weights' => [
+ 'amount' => '10.00',
+ 'weights' => BigDecimals::of('1.5', '2.5'),
+ 'expected' => ['3.75', '6.25']
+ ],
+ 'Negative amount' => [
+ 'amount' => '-100.00',
+ 'weights' => BigDecimals::of('1', '1', '1'),
+ 'expected' => ['-33.33', '-33.33', '-33.34']
+ ],
+ 'Single weight' => [
+ 'amount' => '100.00',
+ 'weights' => BigDecimals::of('1'),
+ 'expected' => ['100.00']
+ ],
+ 'Exact split' => [
+ 'amount' => '100.00',
+ 'weights' => BigDecimals::of('1', '1'),
+ 'expected' => ['50.00', '50.00']
+ ],
+ 'Remainder ordering' => [
+ 'amount' => '0.10',
+ 'weights' => BigDecimals::of('1', '2'),
+ 'expected' => ['0.03', '0.07']
+ ]
+ ];
+ }
+
+ public static function arithmeticProvider(): array
+ {
+ return [
+ 'Different scales' => [
+ 'left' => '1.50',
+ 'right' => '2.250',
+ 'sum' => '3.750',
+ 'difference' => '-0.750',
+ 'product' => '3.37500'
+ ],
+ 'Same scale' => [
+ 'left' => '19.99',
+ 'right' => '5.10',
+ 'sum' => '25.09',
+ 'difference' => '14.89',
+ 'product' => '101.9490'
+ ],
+ 'Negative' => [
+ 'left' => '-1.5',
+ 'right' => '2.5',
+ 'sum' => '1.0',
+ 'difference' => '-4.0',
+ 'product' => '-3.75'
+ ],
+ 'Zero' => [
+ 'left' => '0.00',
+ 'right' => '0.0',
+ 'sum' => '0.00',
+ 'difference' => '0.00',
+ 'product' => '0.000'
+ ]
+ ];
+ }
+
+ public static function squareRootProvider(): array
+ {
+ return [
+ 'Irrational' => [
+ 'value' => '2',
+ 'scale' => 10,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '1.4142135624'
+ ],
+ 'Exact root' => [
+ 'value' => '2.25',
+ 'scale' => 1,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '1.5'
+ ],
+ 'Zero' => [
+ 'value' => '0',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '0.00'
+ ],
+ 'Truncated' => [
+ 'value' => '17',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::Down,
+ 'expected' => '4'
+ ],
+ 'Rounded up' => [
+ 'value' => '17',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::Up,
+ 'expected' => '5'
+ ],
+ 'Exact under up' => [
+ 'value' => '2.25',
+ 'scale' => 1,
+ 'rounding' => RoundingMode::Up,
+ 'expected' => '1.5'
+ ],
+ 'Scale below half' => [
+ 'value' => '0.0001',
+ 'scale' => 1,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '0.0'
+ ]
+ ];
+ }
+
+ public static function trailingZeroProvider(): array
+ {
+ return [
+ 'Some zeros' => ['value' => '1.200', 'expected' => '1.2'],
+ 'All zeros' => ['value' => '1.000', 'expected' => '1'],
+ 'Integral' => ['value' => '100', 'expected' => '100'],
+ 'Zero' => ['value' => '0.000', 'expected' => '0'],
+ 'Negative' => ['value' => '-1.500', 'expected' => '-1.5']
+ ];
+ }
+
+ public static function malformedLiteralProvider(): array
+ {
+ return [
+ 'Empty' => ['value' => ''],
+ 'Sign only' => ['value' => '+'],
+ 'Point only' => ['value' => '.'],
+ 'Letters' => ['value' => 'abc'],
+ 'Dangling e' => ['value' => '1e'],
+ 'Underscores' => ['value' => '1_000'],
+ 'Fraction' => ['value' => '1/2'],
+ 'Two points' => ['value' => '1.2.3'],
+ 'Leading space' => ['value' => ' 1'],
+ 'Huge exponent' => ['value' => '1e10001'],
+ 'Tiny exponent' => ['value' => '1e-10001']
+ ];
+ }
+
+ public static function shortestRoundTripProvider(): array
+ {
+ return [
+ 'Whole' => ['value' => 5.0, 'expected' => '5'],
+ 'One tenth' => ['value' => 0.1, 'expected' => '0.1'],
+ 'One third' => ['value' => (1 / 3), 'expected' => '0.3333333333333333'],
+ 'Negative zero' => ['value' => -0.0, 'expected' => '0'],
+ 'Inexact sum' => ['value' => (0.1 + 0.2), 'expected' => '0.30000000000000004']
+ ];
+ }
+}
diff --git a/tests/Unit/BigDecimalsTest.php b/tests/Unit/BigDecimalsTest.php
new file mode 100644
index 0000000..14f67bf
--- /dev/null
+++ b/tests/Unit/BigDecimalsTest.php
@@ -0,0 +1,130 @@
+ $part->toString(), $actual->all())
+ );
+ }
+
+ public function testAllThenKeepsTheOrderOfTheLiterals(): void
+ {
+ /** @Given three decimal literals in a deliberate order */
+ $values = BigDecimals::of('3', '1', '2');
+
+ /** @When the collection is read back */
+ $actual = $values->all();
+
+ /** @Then the order is untouched */
+ self::assertSame(
+ ['3', '1', '2'],
+ array_map(static fn(BigDecimal $part): string => $part->toString(), $actual)
+ );
+ }
+
+ #[DataProvider('sumProvider')]
+ public function testSumThenStaysExactAtTheLargestScale(BigDecimals $values, string $expected, int $count): void
+ {
+ /** @Given a collection of decimals with its expected total and size */
+
+ /** @When the total is taken */
+ $actual = $values->sum();
+
+ /** @Then the total is exact at the largest scale, and the size is unchanged */
+ self::assertSame($expected, $actual->toString());
+ self::assertCount($count, $values);
+ }
+
+ public function testIterationThenYieldsEveryDecimalInOrder(): void
+ {
+ /** @Given three decimal literals in a deliberate order */
+ $values = BigDecimals::of('3', '1', '2');
+
+ /** @When the collection is iterated */
+ $actual = iterator_to_array($values);
+
+ /** @Then every element is yielded in order */
+ self::assertSame(
+ ['3', '1', '2'],
+ array_map(static fn(BigDecimal $part): string => $part->toString(), $actual)
+ );
+ }
+
+ public function testOfWhenALiteralIsMalformedThenNumberNotWellFormed(): void
+ {
+ /** @Given a literal that is not a number */
+ $value = 'abc';
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When a collection is built from it */
+ BigDecimals::of($value);
+ }
+
+ public function testFromWhenArgumentsAreNamedThenTheCollectionStaysAList(): void
+ {
+ /** @Given a decimal bound to a named argument */
+ $first = BigDecimal::of(value: '1.50');
+
+ /** @And a second decimal bound to another named argument */
+ $second = BigDecimal::of(value: '2.25');
+
+ /** @When a collection is built from them */
+ $actual = BigDecimals::from(first: $first, second: $second);
+
+ /** @Then the names are dropped and the collection stays an ordered list */
+ self::assertSame(
+ ['1.50', '2.25'],
+ array_map(static fn(BigDecimal $part): string => $part->toString(), $actual->all())
+ );
+ }
+
+ public function testJsonSerializeThenEmitsAJsonArrayOfStringsThatReadsBack(): void
+ {
+ /** @Given a collection of decimals */
+ $decimals = BigDecimals::of('1.50', '2.00');
+
+ /** @When it is serialized */
+ $actual = $decimals->jsonSerialize();
+
+ /** @Then it is a list of canonical strings that encodes as a JSON array and reads back */
+ self::assertSame(['1.50', '2.00'], $actual);
+ self::assertSame('["1.50","2.00"]', json_encode($decimals));
+ self::assertEquals($decimals, BigDecimals::of(...$actual));
+ }
+
+ public static function sumProvider(): array
+ {
+ return [
+ 'Empty' => ['values' => BigDecimals::of(), 'expected' => '0', 'count' => 0],
+ 'Single element' => ['values' => BigDecimals::of('1.50'), 'expected' => '1.50', 'count' => 1],
+ 'Mixed scales' => ['values' => BigDecimals::of('1.5', '2.25'), 'expected' => '3.75', 'count' => 2],
+ 'Negative total' => ['values' => BigDecimals::of('1.00', '-2.00'), 'expected' => '-1.00', 'count' => 2],
+ 'Native integers' => ['values' => BigDecimals::of(1, 2, 3), 'expected' => '6', 'count' => 3]
+ ];
+ }
+}
diff --git a/tests/Unit/BigIntegerTest.php b/tests/Unit/BigIntegerTest.php
new file mode 100644
index 0000000..f980b60
--- /dev/null
+++ b/tests/Unit/BigIntegerTest.php
@@ -0,0 +1,722 @@
+toString());
+ }
+
+ public function testZeroThenHoldsZero(): void
+ {
+ /** @Given nothing but the factory */
+
+ /** @When zero is created */
+ $actual = BigInteger::zero();
+
+ /** @Then it holds zero */
+ self::assertSame('0', $actual->toString());
+ }
+
+ #[DataProvider('literalProvider')]
+ public function testOfThenReadsTheLiteral(string|int $value, string $expected): void
+ {
+ /** @Given an integer literal and the value it stands for */
+
+ /** @When an integer is created from it */
+ $actual = BigInteger::of(value: $value);
+
+ /** @Then the value is read exactly */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ public function testNegatedThenFlipsTheSign(): void
+ {
+ /** @Given a positive integer */
+ $number = BigInteger::of(value: 5);
+
+ /** @When it is negated */
+ $actual = $number->negated();
+
+ /** @Then the sign is flipped */
+ self::assertSame('-5', $actual->toString());
+ }
+
+ public function testPlusThenSumsBothIntegers(): void
+ {
+ /** @Given an integer */
+ $augend = BigInteger::of(value: '9223372036854775807');
+
+ /** @And another integer beyond the native range */
+ $addend = BigInteger::of(value: '9223372036854775807');
+
+ /** @When they are added */
+ $actual = $augend->plus(addend: $addend);
+
+ /** @Then the sum keeps every digit */
+ self::assertSame('18446744073709551614', $actual->toString());
+ }
+
+ #[DataProvider('moduloProvider')]
+ public function testModuloThenIsNeverNegative(string $dividend, string $divisor, string $modulo): void
+ {
+ /** @Given a dividend and a modulus with the expected mathematical modulo */
+
+ /** @When the modulo is taken */
+ $actual = BigInteger::of(value: $dividend)->modulo(modulus: BigInteger::of(value: $divisor));
+
+ /** @Then the result is never negative */
+ self::assertSame($modulo, $actual->toString());
+ }
+
+ public function testAbsoluteThenRemovesTheSign(): void
+ {
+ /** @Given a negative integer */
+ $number = BigInteger::of(value: -5);
+
+ /** @When its magnitude is taken */
+ $actual = $number->absolute();
+
+ /** @Then the sign is gone */
+ self::assertSame('5', $actual->toString());
+ }
+
+ #[DataProvider('equalityProvider')]
+ public function testEqualsThenComparesStructurally(string $value, string $other, bool $expected): void
+ {
+ /** @Given two integer literals and whether they are structurally equal */
+
+ /** @When they are compared */
+ $actual = BigInteger::of(value: $value)->equals(other: BigInteger::of(value: $other));
+
+ /** @Then structural equality matches the expectation */
+ self::assertSame($expected, $actual);
+ }
+
+ #[DataProvider('powerProvider')]
+ public function testPowerThenReturnsTheExactResult(string $value, int $exponent, string $power): void
+ {
+ /** @Given a value, an exponent, and the expected power */
+
+ /** @When it is raised to that power */
+ $actual = BigInteger::of(value: $value)->power(exponent: $exponent);
+
+ /** @Then the result is exact */
+ self::assertSame($power, $actual->toString());
+ }
+
+ public function testMinusThenSubtractsTheSubtrahend(): void
+ {
+ /** @Given an integer beyond the float mantissa */
+ $minuend = BigInteger::of(value: '10000000000000000001');
+
+ /** @And a larger integer that differs only in the last digit */
+ $subtrahend = BigInteger::of(value: '10000000000000000003');
+
+ /** @When the larger is subtracted */
+ $actual = $minuend->minus(subtrahend: $subtrahend);
+
+ /** @Then the difference keeps the last digit rather than collapsing to zero */
+ self::assertSame('-2', $actual->toString());
+ }
+
+ #[DataProvider('quotientProvider')]
+ public function testQuotientThenTruncatesTowardZero(string $dividend, string $divisor, string $quotient): void
+ {
+ /** @Given a dividend and a divisor with the expected truncated quotient */
+
+ /** @When the quotient is taken */
+ $actual = BigInteger::of(value: $dividend)->quotient(divisor: BigInteger::of(value: $divisor));
+
+ /** @Then the quotient truncates toward zero */
+ self::assertSame($quotient, $actual->toString());
+ }
+
+ #[DataProvider('signProvider')]
+ public function testSignPredicatesThenAgreeOnTheSign(
+ string $value,
+ bool $isZero,
+ bool $isNegative,
+ bool $isPositive
+ ): void {
+ /** @Given an integer literal and its expected sign */
+
+ /** @When the sign predicates are asked */
+ $actual = BigInteger::of(value: $value);
+
+ /** @Then all three agree */
+ self::assertSame($isZero, $actual->isZero());
+ self::assertSame($isNegative, $actual->isNegative());
+ self::assertSame($isPositive, $actual->isPositive());
+ }
+
+ public function testToBigDecimalThenCarriesScaleZero(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::of(value: -42);
+
+ /** @When it is converted to a decimal */
+ $actual = $number->toBigDecimal();
+
+ /** @Then the decimal has no fractional digits */
+ self::assertSame(0, $actual->scale());
+ }
+
+ public function testJsonSerializeThenEmitsAJsonString(): void
+ {
+ /** @Given an integer beyond the precision of a JSON number */
+ $number = BigInteger::of(value: '9007199254740993');
+
+ /** @When it is encoded */
+ $actual = json_encode($number);
+
+ /** @Then it is a JSON string rather than a JSON number */
+ self::assertSame('"9007199254740993"', $actual);
+ }
+
+ #[DataProvider('rootProvider')]
+ public function testSquareRootThenTruncatesTowardZero(string $value, string $root): void
+ {
+ /** @Given a value and the expected truncated root */
+
+ /** @When its square root is taken */
+ $actual = BigInteger::of(value: $value)->squareRoot();
+
+ /** @Then the root is truncated toward zero */
+ self::assertSame($root, $actual->toString());
+ }
+
+ #[DataProvider('parityProvider')]
+ public function testIsEvenThenReportsDivisibilityByTwo(string $value, bool $expected): void
+ {
+ /** @Given an integer literal and whether it is even */
+
+ /** @When its parity is asked */
+ $actual = BigInteger::of(value: $value);
+
+ /** @Then the answer matches, and is the opposite of being odd */
+ self::assertSame($expected, $actual->isEven());
+ self::assertSame(!$expected, $actual->isOdd());
+ }
+
+ public function testDividedByThenReturnsAnExactFraction(): void
+ {
+ /** @Given an integer */
+ $dividend = BigInteger::of(value: 10);
+
+ /** @And a divisor that does not divide it evenly */
+ $divisor = BigInteger::of(value: 4);
+
+ /** @When it is divided */
+ $actual = $dividend->dividedBy(divisor: $divisor);
+
+ /** @Then the exact fraction is returned in lowest terms */
+ self::assertSame('5/2', $actual->toString());
+ }
+
+ public function testToBigRationalThenHasADenominatorOfOne(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::of(value: 7);
+
+ /** @When it is converted to a fraction */
+ $actual = $number->toBigRational();
+
+ /** @Then the denominator is one */
+ self::assertSame('1', $actual->denominator()->toString());
+ }
+
+ public function testHashCodeWhenValuesMatchThenHashesMatch(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::of(value: 5);
+
+ /** @And another integer holding the same value */
+ $other = BigInteger::of(value: '5.0');
+
+ /** @When both hashes are taken */
+ $actual = $number->hashCode();
+
+ /** @Then they agree */
+ self::assertSame($other->hashCode(), $actual);
+ }
+
+ #[DataProvider('divisorProvider')]
+ public function testGreatestCommonDivisorThenIgnoresTheSigns(string $value, string $other): void
+ {
+ /** @Given two integer literals sharing divisors, in any sign combination */
+
+ /** @When their greatest common divisor is taken */
+ $actual = BigInteger::of(value: $value)->greatestCommonDivisor(other: BigInteger::of(value: $other));
+
+ /** @Then the result is positive whatever the signs were */
+ self::assertSame('6', $actual->toString());
+ }
+
+ #[DataProvider('remainderProvider')]
+ public function testRemainderThenCarriesTheSignOfTheDividend(
+ string $dividend,
+ string $divisor,
+ string $remainder
+ ): void {
+ /** @Given a dividend and a divisor with the expected remainder */
+
+ /** @When the remainder is taken */
+ $actual = BigInteger::of(value: $dividend)->remainder(divisor: BigInteger::of(value: $divisor));
+
+ /** @Then the remainder follows the dividend */
+ self::assertSame($remainder, $actual->toString());
+ }
+
+ #[DataProvider('positionalBaseProvider')]
+ public function testBaseConversionThenRoundTripsThroughTheBase(int $base, string $text, string $value): void
+ {
+ /** @Given a base, a representation in it, and the decimal value */
+
+ /** @When the value is written in that base */
+ $actual = BigInteger::of(value: $value);
+
+ /** @Then the text matches, and reading it back yields the value */
+ self::assertSame($text, $actual->toBase(base: $base));
+ self::assertSame($value, BigInteger::fromBase(base: $base, value: $text)->toString());
+ }
+
+ #[DataProvider('comparisonProvider')]
+ public function testComparisonPredicatesThenAgreeWithCompareTo(string $value, string $other, int $expected): void
+ {
+ /** @Given an integer literal */
+ $number = BigInteger::of(value: $value);
+
+ /** @And another to compare it against */
+ $against = BigInteger::of(value: $other);
+
+ /** @When they are compared */
+ $actual = $number->compareTo(other: $against);
+
+ /** @Then every predicate agrees with the comparison */
+ self::assertSame($expected, $actual);
+ self::assertSame($expected === 0, $number->isEqualTo(other: $against));
+ self::assertSame($expected < 0, $number->isLessThan(other: $against));
+ self::assertSame($expected > 0, $number->isGreaterThan(other: $against));
+ self::assertSame($expected <= 0, $number->isLessThanOrEqualTo(other: $against));
+ self::assertSame($expected >= 0, $number->isGreaterThanOrEqualTo(other: $against));
+ }
+
+ public function testQuotientWhenDivisorIsZeroThenDivisionByZero(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::of(value: 7);
+
+ /** @Then a failure naming the dividend is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Cannot divide <7> by zero.');
+
+ /** @When a truncated quotient by zero is asked for */
+ $number->quotient(divisor: BigInteger::zero());
+ }
+
+ public function testDividedByWhenDivisorIsZeroThenDivisionByZero(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::of(value: 7);
+
+ /** @Then a failure naming the dividend is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Cannot divide <7> by zero.');
+
+ /** @When it is divided by zero */
+ $number->dividedBy(divisor: BigInteger::zero());
+ }
+
+ public function testRemainderWhenDivisorIsZeroThenDivisionByZero(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::of(value: 7);
+
+ /** @Then a failure naming the dividend is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Cannot divide <7> by zero.');
+
+ /** @When a remainder by zero is asked for */
+ $number->remainder(divisor: BigInteger::zero());
+ }
+
+ public function testSquareRootWhenValueIsNegativeThenNegativeRoot(): void
+ {
+ /** @Given a negative integer */
+ $number = BigInteger::of(value: -4);
+
+ /** @Then a failure describing the negative radicand is raised */
+ $this->expectException(NegativeRoot::class);
+ $this->expectExceptionMessage('Cannot take the square root of the negative value <-4>.');
+
+ /** @When its square root is taken */
+ $number->squareRoot();
+ }
+
+ #[DataProvider('nativeRangeProvider')]
+ public function testToIntWhenValueFitsThenReturnsTheNativeInteger(string $value, int $expected): void
+ {
+ /** @Given an integer literal at a boundary of the native range */
+
+ /** @When it is converted to a native integer */
+ $actual = BigInteger::of(value: $value)->toInt();
+
+ /** @Then the boundary itself is accepted */
+ self::assertSame($expected, $actual);
+ }
+
+ public function testMultipliedByThenStaysExactPastTheFloatMantissa(): void
+ {
+ /** @Given an integer beyond the float mantissa */
+ $multiplicand = BigInteger::of(value: '10000000000000000001');
+
+ /** @And a multiplier that forces every digit to carry */
+ $multiplier = BigInteger::of(value: '10000000000000000001');
+
+ /** @When they are multiplied */
+ $actual = $multiplicand->multipliedBy(multiplier: $multiplier);
+
+ /** @Then the product keeps every digit a float would have discarded */
+ self::assertSame('100000000000000000020000000000000000001', $actual->toString());
+ }
+
+ public function testEveryFailureThenCanBeCaughtAsASingleMathFailure(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::one();
+
+ /** @Then the division failure is reachable through the shared contract */
+ $this->expectException(MathFailure::class);
+
+ /** @When it is divided by zero */
+ $number->quotient(divisor: BigInteger::zero());
+ }
+
+ public function testFromBaseWhenValueIsEmptyThenNumberNotWellFormed(): void
+ {
+ /** @Given an empty literal */
+ $value = '';
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When an integer is read in base sixteen */
+ BigInteger::fromBase(base: 16, value: $value);
+ }
+
+ public function testPowerWhenExponentIsNegativeThenNegativeExponent(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::of(value: 2);
+
+ /** @Then a failure describing the negative exponent is raised */
+ $this->expectException(NegativeExponent::class);
+ $this->expectExceptionMessage('Exponent must not be negative, got <-1>.');
+
+ /** @When it is raised to a negative power */
+ $number->power(exponent: -1);
+ }
+
+ #[DataProvider('crossTypeProvider')]
+ public function testCompareToWhenTheOtherIsAnotherTypeThenStaysExact(
+ string $value,
+ Number $other,
+ int $expected
+ ): void {
+ /** @Given an integer and a number of another type with the expected comparison */
+
+ /** @When they are compared */
+ $actual = BigInteger::of(value: $value)->compareTo(other: $other);
+
+ /** @Then the comparison crosses the type boundary */
+ self::assertSame($expected, $actual);
+ }
+
+ public function testFromBaseWhenLiteralIsUppercaseThenItIsReadTheSame(): void
+ {
+ /** @Given a hexadecimal literal written in uppercase */
+ $value = '-FF';
+
+ /** @When it is read in base sixteen */
+ $actual = BigInteger::fromBase(base: 16, value: $value);
+
+ /** @Then the case makes no difference */
+ self::assertSame('-255', $actual->toString());
+ }
+
+ public function testFromBaseWhenSignIsRepeatedThenNumberNotWellFormed(): void
+ {
+ /** @Given a literal carrying more than one leading minus */
+ $value = '--ff';
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When it is read in base sixteen */
+ BigInteger::fromBase(base: 16, value: $value);
+ }
+
+ public function testToIntWhenValueBelowTheNativeRangeThenIntegerOverflow(): void
+ {
+ /** @Given an integer one below the smallest native integer */
+ $number = BigInteger::of(value: '-9223372036854775809');
+
+ /** @Then a failure describing the overflow is raised */
+ $this->expectException(IntegerOverflow::class);
+
+ /** @When it is converted to a native integer */
+ $number->toInt();
+ }
+
+ public function testOfWhenValueCarriesAFractionalPartThenInexactConversion(): void
+ {
+ /** @Given a literal with a non-zero fractional part */
+ $value = '1.5';
+
+ /** @Then a failure describing the lost fractional part is raised */
+ $this->expectException(InexactConversion::class);
+ $this->expectExceptionMessage('Value <1.5> has a fractional part and is not an integer.');
+
+ /** @When an integer is created from it */
+ BigInteger::of(value: $value);
+ }
+
+ public function testToIntWhenValueExceedsTheNativeRangeThenIntegerOverflow(): void
+ {
+ /** @Given an integer one beyond the largest native integer */
+ $number = BigInteger::of(value: '9223372036854775808');
+
+ /** @Then a failure describing the overflow is raised */
+ $this->expectException(IntegerOverflow::class);
+
+ /** @When it is converted to a native integer */
+ $number->toInt();
+ }
+
+ public function testFromBaseWhenBaseIsAboveTheSupportedRangeThenBaseOutOfRange(): void
+ {
+ /** @Given a base above thirty-six */
+ $base = 37;
+
+ /** @Then a failure describing the bounds is raised */
+ $this->expectException(BaseOutOfRange::class);
+
+ /** @When an integer is read in that base */
+ BigInteger::fromBase(base: $base, value: '1');
+ }
+
+ public function testFromBaseWhenCharacterIsNotInTheBaseThenNumberNotWellFormed(): void
+ {
+ /** @Given a literal carrying a character the base does not define */
+ $value = '9';
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When an integer is read in base two */
+ BigInteger::fromBase(base: 2, value: $value);
+ }
+
+ public function testFromBaseWhenBaseIsOutsideTheSupportedRangeThenBaseOutOfRange(): void
+ {
+ /** @Given a base below two */
+ $base = 1;
+
+ /** @Then a failure describing the bounds is raised */
+ $this->expectException(BaseOutOfRange::class);
+ $this->expectExceptionMessage('Base must be within 2 to 36, got <1>.');
+
+ /** @When an integer is read in that base */
+ BigInteger::fromBase(base: $base, value: '1');
+ }
+
+ public function testPowerWhenExponentExceedsTheMagnitudeCeilingThenExponentOutOfRange(): void
+ {
+ /** @Given an integer */
+ $number = BigInteger::of(value: 2);
+
+ /** @Then a failure naming the rejected exponent is raised */
+ $this->expectException(ExponentOutOfRange::class);
+ $this->expectExceptionMessage('Exponent magnitude must not exceed 2147483647, got <2147483648>.');
+
+ /** @When it is raised to an exponent beyond the supported magnitude */
+ $number->power(exponent: 2147483648);
+ }
+
+ public static function rootProvider(): array
+ {
+ return [
+ 'Zero' => ['value' => '0', 'root' => '0'],
+ 'Perfect' => ['value' => '16', 'root' => '4'],
+ 'Truncate' => ['value' => '17', 'root' => '4'],
+ 'Large' => ['value' => '1267650600228229401496703205376', 'root' => '1125899906842624']
+ ];
+ }
+
+ public static function signProvider(): array
+ {
+ return [
+ 'Zero' => ['value' => '0', 'isZero' => true, 'isNegative' => false, 'isPositive' => false],
+ 'Negative' => ['value' => '-1', 'isZero' => false, 'isNegative' => true, 'isPositive' => false],
+ 'Positive' => ['value' => '1', 'isZero' => false, 'isNegative' => false, 'isPositive' => true]
+ ];
+ }
+
+ public static function powerProvider(): array
+ {
+ return [
+ 'Zero' => ['value' => '0', 'exponent' => 0, 'power' => '1'],
+ 'Perfect' => ['value' => '16', 'exponent' => 2, 'power' => '256'],
+ 'Truncate' => ['value' => '17', 'exponent' => 1, 'power' => '17'],
+ 'Large' => ['value' => '2', 'exponent' => 100, 'power' => '1267650600228229401496703205376']
+ ];
+ }
+
+ public static function moduloProvider(): array
+ {
+ return [
+ 'Positive by positive' => ['dividend' => '7', 'divisor' => '2', 'modulo' => '1'],
+ 'Negative by positive' => ['dividend' => '-7', 'divisor' => '2', 'modulo' => '1'],
+ 'Positive by negative' => ['dividend' => '7', 'divisor' => '-2', 'modulo' => '1'],
+ 'Negative by negative' => ['dividend' => '-7', 'divisor' => '-2', 'modulo' => '1'],
+ 'Exact division' => ['dividend' => '8', 'divisor' => '2', 'modulo' => '0']
+ ];
+ }
+
+ public static function parityProvider(): array
+ {
+ return [
+ 'Zero' => ['value' => '0', 'expected' => true],
+ 'Even positive' => ['value' => '4', 'expected' => true],
+ 'Odd positive' => ['value' => '7', 'expected' => false],
+ 'Even negative' => ['value' => '-4', 'expected' => true],
+ 'Odd negative' => ['value' => '-3', 'expected' => false]
+ ];
+ }
+
+ public static function divisorProvider(): array
+ {
+ return [
+ 'Both positive' => ['value' => '12', 'other' => '18'],
+ 'Negative left' => ['value' => '-12', 'other' => '18'],
+ 'Negative and larger' => ['value' => '-18', 'other' => '12'],
+ 'Negative right' => ['value' => '12', 'other' => '-18'],
+ 'Both negative' => ['value' => '-12', 'other' => '-18']
+ ];
+ }
+
+ public static function literalProvider(): array
+ {
+ return [
+ 'Native integer' => ['value' => 42, 'expected' => '42'],
+ 'Leading plus' => ['value' => '+7', 'expected' => '7'],
+ 'Leading zeros' => ['value' => '007', 'expected' => '7'],
+ 'Negative zero' => ['value' => '-0', 'expected' => '0'],
+ 'Zero fractional' => ['value' => '5.00', 'expected' => '5'],
+ 'Scientific notation' => ['value' => '1e3', 'expected' => '1000'],
+ 'Beyond native range' => ['value' => '92233720368547758070', 'expected' => '92233720368547758070']
+ ];
+ }
+
+ public static function equalityProvider(): array
+ {
+ return [
+ 'Same value' => ['value' => '5', 'other' => '5', 'expected' => true],
+ 'Same after trim' => ['value' => '5', 'other' => '5.00', 'expected' => true],
+ 'Different value' => ['value' => '5', 'other' => '6', 'expected' => false],
+ 'Opposite signs' => ['value' => '5', 'other' => '-5', 'expected' => false]
+ ];
+ }
+
+ public static function quotientProvider(): array
+ {
+ return [
+ 'Positive by positive' => ['dividend' => '7', 'divisor' => '2', 'quotient' => '3'],
+ 'Negative by positive' => ['dividend' => '-7', 'divisor' => '2', 'quotient' => '-3'],
+ 'Positive by negative' => ['dividend' => '7', 'divisor' => '-2', 'quotient' => '-3'],
+ 'Negative by negative' => ['dividend' => '-7', 'divisor' => '-2', 'quotient' => '3'],
+ 'Exact division' => ['dividend' => '8', 'divisor' => '2', 'quotient' => '4']
+ ];
+ }
+
+ public static function crossTypeProvider(): array
+ {
+ return [
+ 'Equal to a decimal' => ['value' => '5', 'other' => BigDecimal::of(value: '5.00'), 'expected' => 0],
+ 'Less than a decimal' => ['value' => '5', 'other' => BigDecimal::of(value: '5.01'), 'expected' => -1],
+ 'Greater than a fraction' => ['value' => '5', 'other' => BigRational::of(value: '1/3'), 'expected' => 1]
+ ];
+ }
+
+ public static function remainderProvider(): array
+ {
+ return [
+ 'Positive by positive' => ['dividend' => '7', 'divisor' => '2', 'remainder' => '1'],
+ 'Negative by positive' => ['dividend' => '-7', 'divisor' => '2', 'remainder' => '-1'],
+ 'Positive by negative' => ['dividend' => '7', 'divisor' => '-2', 'remainder' => '1'],
+ 'Negative by negative' => ['dividend' => '-7', 'divisor' => '-2', 'remainder' => '-1'],
+ 'Exact division' => ['dividend' => '8', 'divisor' => '2', 'remainder' => '0']
+ ];
+ }
+
+ public static function comparisonProvider(): array
+ {
+ return [
+ 'Less than' => ['value' => '9', 'other' => '10', 'expected' => -1],
+ 'Digits differ in count' => [
+ 'value' => '99999999999999999999',
+ 'other' => '100000000000000000000',
+ 'expected' => -1
+ ],
+ 'Equal' => ['value' => '10', 'other' => '10', 'expected' => 0],
+ 'Greater than' => ['value' => '10', 'other' => '9', 'expected' => 1]
+ ];
+ }
+
+ public static function nativeRangeProvider(): array
+ {
+ return [
+ 'Largest' => ['value' => '9223372036854775807', 'expected' => PHP_INT_MAX],
+ 'Smallest' => ['value' => '-9223372036854775808', 'expected' => PHP_INT_MIN],
+ 'Zero' => ['value' => '0', 'expected' => 0]
+ ];
+ }
+
+ public static function positionalBaseProvider(): array
+ {
+ return [
+ 'Zero in hex' => ['base' => 16, 'text' => '0', 'value' => '0'],
+ 'Binary' => ['base' => 2, 'text' => '11111111', 'value' => '255'],
+ 'Hexadecimal' => ['base' => 16, 'text' => 'ff', 'value' => '255'],
+ 'Base thirty-six' => ['base' => 36, 'text' => 'zz', 'value' => '1295'],
+ 'Negative in hex' => ['base' => 16, 'text' => '-ff', 'value' => '-255']
+ ];
+ }
+}
diff --git a/tests/Unit/BigRationalTest.php b/tests/Unit/BigRationalTest.php
new file mode 100644
index 0000000..e054604
--- /dev/null
+++ b/tests/Unit/BigRationalTest.php
@@ -0,0 +1,586 @@
+toString());
+ }
+
+ public function testZeroThenHoldsZero(): void
+ {
+ /** @Given nothing but the factory */
+
+ /** @When zero is created */
+ $actual = BigRational::zero();
+
+ /** @Then it holds zero */
+ self::assertSame('0', $actual->toString());
+ }
+
+ public function testNegatedThenFlipsTheSign(): void
+ {
+ /** @Given a fraction */
+ $fraction = BigRational::of(value: '3/4');
+
+ /** @When it is negated */
+ $actual = $fraction->negated();
+
+ /** @Then the numerator carries the sign */
+ self::assertSame('-3/4', $actual->toString());
+ }
+
+ #[DataProvider('arithmeticProvider')]
+ public function testArithmeticThenStaysExact(
+ string $left,
+ string $right,
+ string $sum,
+ string $difference,
+ string $product,
+ string $quotient
+ ): void {
+ /** @Given two fractions and the expected exact results */
+
+ /** @When the four operations run */
+ $actual = BigRational::of(value: $left);
+
+ /** @Then every result is exact and in lowest terms */
+ self::assertSame($sum, $actual->plus(addend: BigRational::of(value: $right))->toString());
+ self::assertSame($difference, $actual->minus(subtrahend: BigRational::of(value: $right))->toString());
+ self::assertSame($product, $actual->multipliedBy(multiplier: BigRational::of(value: $right))->toString());
+ self::assertSame($quotient, $actual->dividedBy(divisor: BigRational::of(value: $right))->toString());
+ }
+
+ public function testAbsoluteThenRemovesTheSign(): void
+ {
+ /** @Given a negative fraction */
+ $fraction = BigRational::of(value: '-3/4');
+
+ /** @When its magnitude is taken */
+ $actual = $fraction->absolute();
+
+ /** @Then the sign is gone */
+ self::assertSame('3/4', $actual->toString());
+ }
+
+ public function testReciprocalThenSwapsTheTerms(): void
+ {
+ /** @Given a fraction */
+ $fraction = BigRational::of(value: '2/3');
+
+ /** @When its reciprocal is taken */
+ $actual = $fraction->reciprocal();
+
+ /** @Then the terms are swapped */
+ self::assertSame('3/2', $actual->toString());
+ }
+
+ #[DataProvider('signProvider')]
+ public function testSignPredicatesThenAgreeOnTheSign(
+ string $value,
+ bool $isZero,
+ bool $isNegative,
+ bool $isPositive
+ ): void {
+ /** @Given a fraction literal and its expected sign */
+
+ /** @When the sign predicates are asked */
+ $actual = BigRational::of(value: $value);
+
+ /** @Then all three agree */
+ self::assertSame($isZero, $actual->isZero());
+ self::assertSame($isNegative, $actual->isNegative());
+ self::assertSame($isPositive, $actual->isPositive());
+ }
+
+ public function testJsonSerializeThenEmitsAJsonString(): void
+ {
+ /** @Given a fraction */
+ $fraction = BigRational::of(value: '3/4');
+
+ /** @When it is encoded */
+ $actual = json_encode($fraction);
+
+ /** @Then it is a JSON string carrying the slash */
+ self::assertSame('"3\/4"', $actual);
+ }
+
+ #[DataProvider('powerProvider')]
+ public function testPowerThenAcceptsNegativeExponents(string $value, int $exponent, string $expected): void
+ {
+ /** @Given a fraction, an exponent, and the expected exact power */
+
+ /** @When it is raised to that power */
+ $actual = BigRational::of(value: $value)->power(exponent: $exponent);
+
+ /** @Then the result is exact, including for a negative exponent */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('floatProvider')]
+ public function testToFloatThenReturnsTheNearestFloat(string $value, float $expected): void
+ {
+ /** @Given a fraction and the float nearest to it */
+
+ /** @When it is converted to a float */
+ $actual = BigRational::of(value: $value)->toFloat();
+
+ /** @Then the nearest float is returned */
+ self::assertSame($expected, $actual);
+ }
+
+ #[DataProvider('literalProvider')]
+ public function testOfThenReadsTheLiteralInLowestTerms(string|int $value, string $expected): void
+ {
+ /** @Given a literal and the fraction it stands for */
+
+ /** @When a fraction is created from it */
+ $actual = BigRational::of(value: $value);
+
+ /** @Then the fraction is reduced and the sign sits on the numerator */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('decimalProvider')]
+ public function testToDecimalThenRoundsAtTheRequestedScale(
+ string $value,
+ int $scale,
+ RoundingMode $rounding,
+ string $expected
+ ): void {
+ /** @Given a fraction, a target scale, and a rounding mode */
+
+ /** @When it is converted to a decimal */
+ $actual = BigRational::of(value: $value)->toDecimal(scale: $scale, rounding: $rounding);
+
+ /** @Then the decimal is rounded as instructed */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('comparisonProvider')]
+ public function testCompareToThenWorksAcrossEveryNumberType(string $value, string $other, int $expected): void
+ {
+ /** @Given a fraction literal and a decimal literal with the expected comparison */
+
+ /** @When they are compared */
+ $actual = BigRational::of(value: $value)->compareTo(other: BigDecimal::of(value: $other));
+
+ /** @Then the comparison crosses the type boundary */
+ self::assertSame($expected, $actual);
+ }
+
+ public function testToBigRationalThenReturnsTheSameInstance(): void
+ {
+ /** @Given a fraction */
+ $fraction = BigRational::of(value: '2/3');
+
+ /** @When it is asked for its rational form */
+ $actual = $fraction->toBigRational();
+
+ /** @Then it hands back itself */
+ self::assertSame($fraction, $actual);
+ }
+
+ public function testEqualsWhenFractionsMatchThenTheyAreEqual(): void
+ {
+ /** @Given a fraction */
+ $fraction = BigRational::of(value: '3/4');
+
+ /** @And the same value written unreduced */
+ $other = BigRational::of(value: '6/8');
+
+ /** @When they are compared structurally */
+ $actual = $fraction->equals(other: $other);
+
+ /** @Then they are equal, because a fraction is always stored in lowest terms */
+ self::assertTrue($actual);
+ }
+
+ public function testHashCodeWhenFractionsMatchThenHashesMatch(): void
+ {
+ /** @Given a fraction */
+ $fraction = BigRational::of(value: '3/4');
+
+ /** @And the same value written unreduced */
+ $other = BigRational::of(value: '6/8');
+
+ /** @When both hashes are taken */
+ $actual = $fraction->hashCode();
+
+ /** @Then they agree */
+ self::assertSame($other->hashCode(), $actual);
+ }
+
+ #[DataProvider('exactDecimalProvider')]
+ public function testToDecimalExactThenConvertsAtTheMinimalScale(string $value, string $expected): void
+ {
+ /** @Given a terminating fraction and the decimal it stands for */
+
+ /** @When it is converted without rounding */
+ $actual = BigRational::of(value: $value)->toDecimalExact();
+
+ /** @Then the conversion lands at the smallest scale that represents it */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ public function testDividedByWhenDivisorIsZeroThenDivisionByZero(): void
+ {
+ /** @Given a fraction */
+ $fraction = BigRational::of(value: '3/4');
+
+ /** @Then a failure naming the dividend is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Cannot divide <3/4> by zero.');
+
+ /** @When it is divided by zero */
+ $fraction->dividedBy(divisor: BigRational::zero());
+ }
+
+ public function testReciprocalWhenFractionIsZeroThenDivisionByZero(): void
+ {
+ /** @Given a fraction holding zero */
+ $fraction = BigRational::zero();
+
+ /** @Then a failure describing the undefined reciprocal is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('The reciprocal of zero is undefined.');
+
+ /** @When its reciprocal is taken */
+ $fraction->reciprocal();
+ }
+
+ #[DataProvider('terminatingProvider')]
+ public function testTerminatingExpansionThenIsReportedForBothKinds(string $value, bool $terminates): void
+ {
+ /** @Given a fraction and whether its decimal expansion terminates */
+
+ /** @When the question is asked */
+ $actual = BigRational::of(value: $value)->hasTerminatingDecimal();
+
+ /** @Then the answer matches */
+ self::assertSame($terminates, $actual);
+ }
+
+ #[DataProvider('termsProvider')]
+ public function testNumeratorAndDenominatorThenExposeTheReducedTerms(
+ string $value,
+ string $numerator,
+ string $denominator
+ ): void {
+ /** @Given a literal with its reduced terms */
+
+ /** @When both terms are taken */
+ $actual = BigRational::of(value: $value);
+
+ /** @Then the denominator is strictly positive and the numerator carries the sign */
+ self::assertSame($numerator, $actual->numerator()->toString());
+ self::assertSame($denominator, $actual->denominator()->toString());
+ }
+
+ public function testOfFractionWhenDenominatorIsZeroThenDivisionByZero(): void
+ {
+ /** @Given a numerator */
+ $numerator = BigInteger::of(value: 3);
+
+ /** @Then a failure naming the numerator is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Fraction <3> cannot have a denominator of zero.');
+
+ /** @When a fraction is built over a zero denominator */
+ BigRational::ofFraction(numerator: $numerator, denominator: BigInteger::zero());
+ }
+
+ public function testEqualsWhenOnlyTheNumeratorMatchesThenTheyAreNotEqual(): void
+ {
+ /** @Given a fraction */
+ $fraction = BigRational::of(value: '1/2');
+
+ /** @And another fraction sharing the numerator but not the denominator */
+ $other = BigRational::of(value: '1/3');
+
+ /** @When they are compared structurally */
+ $actual = $fraction->equals(other: $other);
+
+ /** @Then they are not equal, because both terms take part */
+ self::assertFalse($actual);
+ }
+
+ public function testOfWhenLiteralCarriesTwoSlashesThenNumberNotWellFormed(): void
+ {
+ /** @Given a literal with more than one slash */
+ $value = '1/2/3';
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When a fraction is created from it */
+ BigRational::of(value: $value);
+ }
+
+ public function testToBigIntegerWhenDenominatorIsOneThenReturnsTheNumerator(): void
+ {
+ /** @Given a fraction that reduces to a whole number */
+ $fraction = BigRational::of(value: '8/4');
+
+ /** @When it is converted to an integer */
+ $actual = $fraction->toBigInteger();
+
+ /** @Then it carries the numerator of the reduced fraction */
+ self::assertSame('2', $actual->toString());
+ }
+
+ public function testToBigIntegerWhenDenominatorIsNotOneThenInexactConversion(): void
+ {
+ /** @Given a fraction that is not an integer */
+ $fraction = BigRational::of(value: '1/3');
+
+ /** @Then a failure describing the lost fractional part is raised */
+ $this->expectException(InexactConversion::class);
+
+ /** @When it is converted to an integer */
+ $fraction->toBigInteger();
+ }
+
+ public function testToDecimalExactWhenExpansionRepeatsThenNonTerminatingDecimal(): void
+ {
+ /** @Given a fraction whose decimal expansion repeats forever */
+ $fraction = BigRational::of(value: '1/3');
+
+ /** @Then a failure describing the repeating expansion is raised */
+ $this->expectException(NonTerminatingDecimal::class);
+ $this->expectExceptionMessage('Fraction <1/3> has a non-terminating decimal expansion.');
+
+ /** @When an exact decimal is demanded */
+ $fraction->toDecimalExact();
+ }
+
+ public function testPowerWhenExponentIsNegativeAndFractionIsZeroThenDivisionByZero(): void
+ {
+ /** @Given a fraction holding zero */
+ $fraction = BigRational::zero();
+
+ /** @Then a failure describing the undefined reciprocal is raised */
+ $this->expectException(DivisionByZero::class);
+
+ /** @When it is raised to a negative power */
+ $fraction->power(exponent: -2);
+ }
+
+ public function testPowerWhenExponentExceedsTheMagnitudeCeilingThenExponentOutOfRange(): void
+ {
+ /** @Given a fraction holding zero */
+ $fraction = BigRational::zero();
+
+ /** @Then a failure naming the rejected exponent is raised */
+ $this->expectException(ExponentOutOfRange::class);
+ $this->expectExceptionMessage('Exponent magnitude must not exceed 2147483647, got <-2147483648>.');
+
+ /** @When it is raised to a negative exponent beyond the supported magnitude */
+ $fraction->power(exponent: -2147483648);
+ }
+
+ public function testPowerWhenExponentIsAtTheNegativeMagnitudeLimitThenTheBoundIsCleared(): void
+ {
+ /** @Given a fraction holding zero */
+ $fraction = BigRational::zero();
+
+ /** @Then the reciprocal is what fails, so the exponent cleared the magnitude bound */
+ $this->expectException(DivisionByZero::class);
+
+ /** @When it is raised to the largest negative exponent the library accepts */
+ $fraction->power(exponent: -2147483647);
+ }
+
+ public static function signProvider(): array
+ {
+ return [
+ 'Zero' => ['value' => '0', 'isZero' => true, 'isNegative' => false, 'isPositive' => false],
+ 'Negative' => ['value' => '-1/2', 'isZero' => false, 'isNegative' => true, 'isPositive' => false],
+ 'Positive' => ['value' => '1/2', 'isZero' => false, 'isNegative' => false, 'isPositive' => true]
+ ];
+ }
+
+ public static function floatProvider(): array
+ {
+ return [
+ 'One third' => ['value' => '1/3', 'expected' => (1 / 3)],
+ 'One half' => ['value' => '1/2', 'expected' => 0.5],
+ 'Negative' => ['value' => '-1/4', 'expected' => -0.25],
+ 'Small' => ['value' => '1/30000000000', 'expected' => (1 / 30000000000)],
+ 'Small negative' => ['value' => '-1/30000000000', 'expected' => (-1 / 30000000000)],
+ 'Very small' => ['value' => '1/1000000000000000000000', 'expected' => 1.0E-21],
+ 'Below a float' => ['value' => '1/1e400', 'expected' => 0.0],
+ 'Digit sensitive' => ['value' => '155055128/874831071', 'expected' => 0.17724007884489051],
+ 'Wide integral' => ['value' => '123456789012345678/3', 'expected' => (123456789012345678 / 3)]
+ ];
+ }
+
+ public static function powerProvider(): array
+ {
+ return [
+ 'Zero exponent' => ['value' => '2/3', 'exponent' => 0, 'expected' => '1'],
+ 'Zero base' => ['value' => '0', 'exponent' => 0, 'expected' => '1'],
+ 'Positive exponent' => ['value' => '2/3', 'exponent' => 2, 'expected' => '4/9'],
+ 'Negative exponent' => ['value' => '2/3', 'exponent' => -2, 'expected' => '9/4']
+ ];
+ }
+
+ public static function termsProvider(): array
+ {
+ return [
+ 'Reduced' => ['value' => '6/8', 'numerator' => '3', 'denominator' => '4'],
+ 'Negative numerator' => ['value' => '-3/4', 'numerator' => '-3', 'denominator' => '4'],
+ 'Negative denominator' => ['value' => '3/-4', 'numerator' => '-3', 'denominator' => '4'],
+ 'Zero' => ['value' => '0/5', 'numerator' => '0', 'denominator' => '1']
+ ];
+ }
+
+ public static function decimalProvider(): array
+ {
+ return [
+ 'Repeating' => [
+ 'value' => '100/3',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '33.33'
+ ],
+ 'Exact' => [
+ 'value' => '1/4',
+ 'scale' => 2,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '0.25'
+ ],
+ 'Negative' => [
+ 'value' => '-1/3',
+ 'scale' => 3,
+ 'rounding' => RoundingMode::Floor,
+ 'expected' => '-0.334'
+ ],
+ 'Scale zero' => [
+ 'value' => '3/2',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::HalfEven,
+ 'expected' => '2'
+ ],
+ 'Padded scale' => [
+ 'value' => '1/2',
+ 'scale' => 4,
+ 'rounding' => RoundingMode::Down,
+ 'expected' => '0.5000'
+ ],
+ 'Exact upward' => [
+ 'value' => '1/2',
+ 'scale' => 1,
+ 'rounding' => RoundingMode::Up,
+ 'expected' => '0.5'
+ ],
+ 'Negative half' => [
+ 'value' => '-1/2',
+ 'scale' => 0,
+ 'rounding' => RoundingMode::HalfUp,
+ 'expected' => '-1'
+ ]
+ ];
+ }
+
+ public static function literalProvider(): array
+ {
+ return [
+ 'Fraction' => ['value' => '3/4', 'expected' => '3/4'],
+ 'Unreduced fraction' => ['value' => '6/8', 'expected' => '3/4'],
+ 'Decimal' => ['value' => '0.75', 'expected' => '3/4'],
+ 'Integer' => ['value' => '3', 'expected' => '3'],
+ 'Native integer' => ['value' => -3, 'expected' => '-3'],
+ 'Negative denominator' => ['value' => '3/-4', 'expected' => '-3/4'],
+ 'Whole fraction' => ['value' => '8/4', 'expected' => '2']
+ ];
+ }
+
+ public static function arithmeticProvider(): array
+ {
+ return [
+ 'Thirds and sixths' => [
+ 'left' => '1/3',
+ 'right' => '1/6',
+ 'sum' => '1/2',
+ 'difference' => '1/6',
+ 'product' => '1/18',
+ 'quotient' => '2'
+ ],
+ 'Negative' => [
+ 'left' => '-1/2',
+ 'right' => '1/4',
+ 'sum' => '-1/4',
+ 'difference' => '-3/4',
+ 'product' => '-1/8',
+ 'quotient' => '-2'
+ ],
+ 'Whole numbers' => [
+ 'left' => '4',
+ 'right' => '2',
+ 'sum' => '6',
+ 'difference' => '2',
+ 'product' => '8',
+ 'quotient' => '2'
+ ]
+ ];
+ }
+
+ public static function comparisonProvider(): array
+ {
+ return [
+ 'Less than' => ['value' => '1/3', 'other' => '0.5', 'expected' => -1],
+ 'Equal' => ['value' => '1/2', 'other' => '0.50', 'expected' => 0],
+ 'Greater than' => ['value' => '2/3', 'other' => '0.5', 'expected' => 1]
+ ];
+ }
+
+ public static function terminatingProvider(): array
+ {
+ return [
+ 'Halves' => ['value' => '1/2', 'terminates' => true],
+ 'Thirds' => ['value' => '1/3', 'terminates' => false],
+ 'Sixths' => ['value' => '1/6', 'terminates' => false],
+ 'Eighths' => ['value' => '1/8', 'terminates' => true],
+ 'Sevenths' => ['value' => '1/7', 'terminates' => false],
+ 'Hundredths' => ['value' => '1/100', 'terminates' => true],
+ 'Twentieths' => ['value' => '1/20', 'terminates' => true],
+ 'Integer' => ['value' => '3', 'terminates' => true],
+ 'Zero' => ['value' => '0', 'terminates' => true]
+ ];
+ }
+
+ public static function exactDecimalProvider(): array
+ {
+ return [
+ 'Halves' => ['value' => '1/2', 'expected' => '0.5'],
+ 'Eighths' => ['value' => '1/8', 'expected' => '0.125'],
+ 'Hundredths' => ['value' => '1/100', 'expected' => '0.01'],
+ 'Twentieths' => ['value' => '1/20', 'expected' => '0.05'],
+ 'Integer' => ['value' => '3', 'expected' => '3'],
+ 'Zero' => ['value' => '0', 'expected' => '0']
+ ];
+ }
+}
diff --git a/tests/Unit/CalculatorsTest.php b/tests/Unit/CalculatorsTest.php
new file mode 100644
index 0000000..2d831e7
--- /dev/null
+++ b/tests/Unit/CalculatorsTest.php
@@ -0,0 +1,101 @@
+getConstructor()?->invoke($surface->newInstanceWithoutConstructor());
+
+ /** @Then it is private, so no public path can do the same */
+ self::assertTrue($surface->getConstructor()?->isPrivate());
+ }
+
+ #[RequiresPhpExtension('bcmath')]
+ public function testRegisterWhenBackendIsAvailableThenArithmeticRunsThroughIt(): void
+ {
+ /** @Given a backend that records how often it is asked to add */
+ $calculator = new CountingCalculatorMock();
+
+ /** @And that backend registered for the process */
+ Calculators::register(calculator: $calculator);
+
+ /** @When an addition runs */
+ BigDecimal::one()->plus(addend: BigDecimal::one());
+
+ /** @Then the registered backend did the work */
+ self::assertGreaterThan(0, $calculator->additions());
+ }
+
+ public function testRegisterWhenBackendIsUnavailableThenCalculatorNotAvailable(): void
+ {
+ /** @Given a backend that reports itself unavailable */
+ $calculator = new UnavailableCalculatorMock();
+
+ /** @Then a failure naming the rejected backend is raised */
+ $this->expectException(CalculatorNotAvailable::class);
+ $this->expectExceptionMessage('Calculator is not');
+
+ /** @When it is registered */
+ Calculators::register(calculator: $calculator);
+ }
+
+ #[RequiresPhpExtension('bcmath')]
+ public function testResetWhenABackendWasRegisteredThenReturnsToAutomaticResolution(): void
+ {
+ /** @Given a registered backend that is not the one resolution would pick */
+ Calculators::register(calculator: new CountingCalculatorMock());
+
+ /** @When automatic resolution is restored */
+ Calculators::reset();
+
+ /** @Then the resolved backend is in use again */
+ self::assertInstanceOf(BcMathCalculator::class, Calculators::active());
+ }
+}
diff --git a/tests/Unit/CountingCalculatorMock.php b/tests/Unit/CountingCalculatorMock.php
new file mode 100644
index 0000000..18c9d5a
--- /dev/null
+++ b/tests/Unit/CountingCalculatorMock.php
@@ -0,0 +1,72 @@
+delegate = new BcMathCalculator();
+ }
+
+ public function add(string $left, string $right): string
+ {
+ $this->additions++;
+
+ return $this->delegate->add(left: $left, right: $right);
+ }
+
+ public function power(string $base, int $exponent): string
+ {
+ return $this->delegate->power(base: $base, exponent: $exponent);
+ }
+
+ public function compare(string $left, string $right): int
+ {
+ return $this->delegate->compare(left: $left, right: $right);
+ }
+
+ public function multiply(string $left, string $right): string
+ {
+ return $this->delegate->multiply(left: $left, right: $right);
+ }
+
+ public function quotient(string $numerator, string $denominator): string
+ {
+ return $this->delegate->quotient(numerator: $numerator, denominator: $denominator);
+ }
+
+ public function subtract(string $minuend, string $subtrahend): string
+ {
+ return $this->delegate->subtract(minuend: $minuend, subtrahend: $subtrahend);
+ }
+
+ public function additions(): int
+ {
+ return $this->additions;
+ }
+
+ public function remainder(string $numerator, string $denominator): string
+ {
+ return $this->delegate->remainder(numerator: $numerator, denominator: $denominator);
+ }
+
+ public function squareRoot(string $radicand): string
+ {
+ return $this->delegate->squareRoot(radicand: $radicand);
+ }
+
+ public function isAvailable(): bool
+ {
+ return $this->delegate->isAvailable();
+ }
+}
diff --git a/tests/Unit/NativeCalculatorDifferentialTest.php b/tests/Unit/NativeCalculatorDifferentialTest.php
new file mode 100644
index 0000000..2ba0193
--- /dev/null
+++ b/tests/Unit/NativeCalculatorDifferentialTest.php
@@ -0,0 +1,257 @@
+add(left: $left, right: $right);
+ $actual[sprintf($template, 'add', $left, $right)] = $native->add(left: $left, right: $right);
+
+ $expected[sprintf($template, 'subtract', $left, $right)] =
+ $extension->subtract(minuend: $left, subtrahend: $right);
+ $actual[sprintf($template, 'subtract', $left, $right)] =
+ $native->subtract(minuend: $left, subtrahend: $right);
+
+ $expected[sprintf($template, 'multiply', $left, $right)] =
+ $extension->multiply(left: $left, right: $right);
+ $actual[sprintf($template, 'multiply', $left, $right)] =
+ $native->multiply(left: $left, right: $right);
+
+ $expected[sprintf($template, 'compare', $left, $right)] =
+ $extension->compare(left: $left, right: $right);
+ $actual[sprintf($template, 'compare', $left, $right)] =
+ $native->compare(left: $left, right: $right);
+ }
+ }
+
+ /** @Then the pure PHP backend reproduces libbcmath digit for digit */
+ self::assertSame($expected, $actual);
+ }
+
+ public function testEveryRootThenAgreesWithTheExtension(): void
+ {
+ /** @Given the pure PHP backend */
+ $native = new NativeCalculator();
+
+ /** @And libbcmath as the reference implementation */
+ $extension = new BcMathCalculator();
+
+ /** @When the truncated integer root runs over every non-negative radicand */
+ $expected = [];
+ $actual = [];
+ $radicands = array_filter(
+ self::OPERANDS,
+ static fn(string $value): bool => !str_starts_with($value, '-') || ltrim($value, '-') === '0'
+ );
+
+ foreach ($radicands as $radicand) {
+ $expected[$radicand] = $extension->squareRoot(radicand: $radicand);
+ $actual[$radicand] = $native->squareRoot(radicand: $radicand);
+ }
+
+ /** @Then every root lands on the digit libbcmath reports */
+ self::assertSame($expected, $actual);
+ }
+
+ public function testEveryPowerThenAgreesWithTheExtension(): void
+ {
+ /** @Given the pure PHP backend */
+ $native = new NativeCalculator();
+
+ /** @And libbcmath as the reference implementation */
+ $extension = new BcMathCalculator();
+
+ /** @When every base is raised to every exponent in the corpus */
+ $expected = [];
+ $actual = [];
+
+ foreach (self::OPERANDS as $base) {
+ foreach (self::EXPONENTS as $exponent) {
+ $template = '%s^%d';
+
+ $expected[sprintf($template, $base, $exponent)] =
+ $extension->power(base: $base, exponent: $exponent);
+ $actual[sprintf($template, $base, $exponent)] =
+ $native->power(base: $base, exponent: $exponent);
+ }
+ }
+
+ /** @Then squaring and multiplying reproduce libbcmath exactly */
+ self::assertSame($expected, $actual);
+ }
+
+ public function testEveryDivisionThenAgreesWithTheExtension(): void
+ {
+ /** @Given the pure PHP backend */
+ $native = new NativeCalculator();
+
+ /** @And libbcmath as the reference implementation */
+ $extension = new BcMathCalculator();
+
+ /** @When the truncating division runs over every ordered pair with a non-zero divisor */
+ $expected = [];
+ $actual = [];
+ $divisors = array_filter(self::OPERANDS, static fn(string $value): bool => ltrim($value, '-') !== '0');
+
+ foreach (self::OPERANDS as $numerator) {
+ foreach ($divisors as $denominator) {
+ $template = '%s(%s, %s)';
+
+ $expected[sprintf($template, 'quotient', $numerator, $denominator)] =
+ $extension->quotient(numerator: $numerator, denominator: $denominator);
+ $actual[sprintf($template, 'quotient', $numerator, $denominator)] =
+ $native->quotient(numerator: $numerator, denominator: $denominator);
+
+ $expected[sprintf($template, 'remainder', $numerator, $denominator)] =
+ $extension->remainder(numerator: $numerator, denominator: $denominator);
+ $actual[sprintf($template, 'remainder', $numerator, $denominator)] =
+ $native->remainder(numerator: $numerator, denominator: $denominator);
+ }
+ }
+
+ /** @Then quotient and remainder match libbcmath, signs included */
+ self::assertSame($expected, $actual);
+ }
+
+ #[DataProvider('backendProvider')]
+ public function testQuotientWhenDivisorIsZeroThenBothRefuse(Calculator $calculator): void
+ {
+ /** @Given a backend and a divisor the contract forbids */
+
+ /** @Then a failure naming the dividend is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Cannot divide <7> by zero.');
+
+ /** @When the truncating division runs */
+ $calculator->quotient(numerator: '7', denominator: '0');
+ }
+
+ #[DataProvider('backendProvider')]
+ public function testRemainderWhenDivisorIsZeroThenBothRefuse(Calculator $calculator): void
+ {
+ /** @Given a backend and a divisor the contract forbids */
+
+ /** @Then a failure naming the dividend is raised */
+ $this->expectException(DivisionByZero::class);
+ $this->expectExceptionMessage('Cannot divide <7> by zero.');
+
+ /** @When the remainder runs */
+ $calculator->remainder(numerator: '7', denominator: '0');
+ }
+
+ #[DataProvider('backendProvider')]
+ public function testPowerWhenExponentIsNegativeThenBothRefuse(Calculator $calculator): void
+ {
+ /** @Given a backend and an exponent the contract forbids */
+
+ /** @Then a failure naming the exponent is raised */
+ $this->expectException(NegativeExponent::class);
+
+ /** @When the power runs */
+ $calculator->power(base: '1', exponent: -1);
+ }
+
+ #[DataProvider('backendProvider')]
+ public function testSquareRootWhenRadicandIsNegativeThenBothRefuse(Calculator $calculator): void
+ {
+ /** @Given a backend and a radicand the contract forbids */
+
+ /** @Then a failure naming the radicand is raised */
+ $this->expectException(NegativeRoot::class);
+ $this->expectExceptionMessage('Cannot take the square root of the negative value <-4>.');
+
+ /** @When the root runs */
+ $calculator->squareRoot(radicand: '-4');
+ }
+
+ #[DataProvider('hardDivisionProvider')]
+ public function testDivisionWhenTheDigitEstimateOvershootsThenAgreesWithTheExtension(
+ string $numerator,
+ string $denominator
+ ): void {
+ /** @Given a pair whose quotient digit needs the full correction the estimate allows */
+
+ /** @When the truncating division runs on the pure PHP backend */
+ $actual = new NativeCalculator()->quotient(numerator: $numerator, denominator: $denominator);
+
+ /** @Then it still lands on the digits libbcmath reports */
+ self::assertSame(new BcMathCalculator()->quotient(numerator: $numerator, denominator: $denominator), $actual);
+ }
+
+ public static function backendProvider(): array
+ {
+ return [
+ 'Extension' => ['calculator' => new BcMathCalculator()],
+ 'Pure PHP' => ['calculator' => new NativeCalculator()]
+ ];
+ }
+
+ public static function hardDivisionProvider(): array
+ {
+ return [
+ 'Two limb divisor' => [
+ 'numerator' => '459714127023879285092811644',
+ 'denominator' => '501933633760608253'
+ ],
+ 'Three limb divisor' => [
+ 'numerator' => '504742242870603765137420602077423734',
+ 'denominator' => '589524224922116716183555725'
+ ],
+ 'Narrow overshoot' => [
+ 'numerator' => '595293886058520101586123288',
+ 'denominator' => '695280179905058033'
+ ],
+ 'Wide overshoot' => [
+ 'numerator' => '528914152814300517288849623674805144',
+ 'denominator' => '569339172710255931955000324'
+ ]
+ ];
+ }
+}
diff --git a/tests/Unit/NativeCalculatorTest.php b/tests/Unit/NativeCalculatorTest.php
new file mode 100644
index 0000000..cec043d
--- /dev/null
+++ b/tests/Unit/NativeCalculatorTest.php
@@ -0,0 +1,355 @@
+squareRoot();
+
+ /** @Then the digit-by-digit pair extraction settles on the floor of the exact root */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('additionProvider')]
+ public function testAdditionThenMatchesTheDocumentedResult(string $left, string $right, string $expected): void
+ {
+ /** @Given two integers and the exact sum */
+
+ /** @When they are added through the pure PHP backend */
+ $actual = BigInteger::of(value: $left)->plus(addend: BigInteger::of(value: $right));
+
+ /** @Then the sum crosses every limb boundary intact */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('powerProvider')]
+ public function testPowerThenMatchesRepeatedMultiplication(string $base, int $exponent, string $expected): void
+ {
+ /** @Given a base, an exponent, and the exact power */
+
+ /** @When it is raised through the pure PHP backend */
+ $actual = BigInteger::of(value: $base)->power(exponent: $exponent);
+
+ /** @Then squaring and multiplying agree with repeated multiplication */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('comparisonProvider')]
+ public function testComparisonThenOrdersAcrossTheSignBoundary(string $left, string $right, int $expected): void
+ {
+ /** @Given two integers and the expected ordering */
+
+ /** @When they are compared through the pure PHP backend */
+ $actual = BigInteger::of(value: $left)->compareTo(other: BigInteger::of(value: $right));
+
+ /** @Then the ordering holds across signs and limb counts */
+ self::assertSame($expected, $actual);
+ }
+
+ #[DataProvider('subtractionProvider')]
+ public function testSubtractionThenMatchesTheDocumentedResult(string $left, string $right, string $expected): void
+ {
+ /** @Given two integers and the exact difference */
+
+ /** @When one is subtracted from the other through the pure PHP backend */
+ $actual = BigInteger::of(value: $left)->minus(subtrahend: BigInteger::of(value: $right));
+
+ /** @Then every borrow propagates across the limbs */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ public function testDecimalArithmeticThenStaysExactWithoutBcMath(): void
+ {
+ /** @Given a price beyond the float mantissa */
+ $price = BigDecimal::of(value: '19.99');
+
+ /** @When it is scaled and rounded through the pure PHP backend */
+ $actual = $price->multipliedBy(multiplier: BigDecimal::of(value: '0.875'));
+
+ /** @Then the decimal path produces the same exact result the extension would */
+ self::assertSame('17.49125', $actual->toString());
+ self::assertSame('17.49', $actual->toScale(scale: 2, rounding: RoundingMode::HalfEven)->toString());
+ }
+
+ #[DataProvider('productProvider')]
+ public function testMultiplicationThenMatchesTheDocumentedResult(
+ string $left,
+ string $right,
+ string $expected
+ ): void {
+ /** @Given two integers and the exact product */
+
+ /** @When they are multiplied through the pure PHP backend */
+ $actual = BigInteger::of(value: $left)->multipliedBy(multiplier: BigInteger::of(value: $right));
+
+ /** @Then every carry lands in the right limb */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('divisionProvider')]
+ public function testDivisionThenFollowsTheDocumentedSignConventions(
+ string $numerator,
+ string $denominator,
+ string $quotient,
+ string $remainder
+ ): void {
+ /** @Given a dividend, a divisor, and both documented results */
+
+ /** @When the integer division runs through the pure PHP backend */
+ $actual = BigInteger::of(value: $numerator);
+
+ /** @Then the quotient truncates toward zero and the remainder follows the dividend */
+ self::assertSame($quotient, $actual->quotient(divisor: BigInteger::of(value: $denominator))->toString());
+ self::assertSame($remainder, $actual->remainder(divisor: BigInteger::of(value: $denominator))->toString());
+ }
+
+ public function testIsAvailableThenReportsWhetherIntegersAreWideEnough(): void
+ {
+ /** @Given the pure PHP backend */
+ $calculator = new NativeCalculator();
+
+ /** @When it is asked whether it can run */
+ $actual = $calculator->isAvailable();
+
+ /** @Then it runs here, because this platform carries 64-bit integers */
+ self::assertTrue($actual);
+ self::assertSame(8, PHP_INT_SIZE);
+ }
+
+ #[RequiresPhpExtension('bcmath')]
+ public function testEveryCollaboratorThenProducesTheSameResultAsTheExtension(): void
+ {
+ /** @Given a workload that reaches rounding, fractions, bases, allocation and roots */
+ $workload = static fn(): array => [
+ 'divided' => BigDecimal::of(value: '123456789012345.6789')
+ ->dividedBy(divisor: BigDecimal::of(value: '9876543.21'))
+ ->toDecimal(scale: 12, rounding: RoundingMode::HalfEven)
+ ->toString(),
+ 'reduced' => BigRational::of(value: '123456789012345678/98765432109876')->toString(),
+ 'commonRoot' => BigInteger::of(value: '123456789012345678')
+ ->greatestCommonDivisor(other: BigInteger::of(value: '98765432109876'))
+ ->toString(),
+ 'base' => BigInteger::of(value: '123456789012345678901234567890')->toBase(base: 36),
+ 'allocated' => implode(',', array_map(
+ static fn(BigDecimal $part): string => $part->toString(),
+ BigDecimal::of(value: '123456789012345.67')
+ ->allocate(scale: 2, weights: BigDecimals::of('1', '2', '7'))
+ ->all()
+ )),
+ 'root' => BigDecimal::of(value: '123456789012345.6789')
+ ->squareRoot(scale: 10, rounding: RoundingMode::HalfEven)
+ ->toString()
+ ];
+
+ /** @When it runs once on the extension and once on the pure PHP backend */
+ Calculators::register(calculator: new BcMathCalculator());
+ $expected = $workload();
+
+ Calculators::register(calculator: new NativeCalculator());
+ $actual = $workload();
+
+ /** @Then every collaborator that routes through the backend produces the same digits */
+ self::assertSame($expected, $actual);
+ }
+
+ public function testResolutionWhenNoCandidateCanRunThenCalculatorNotAvailable(): void
+ {
+ /** @Given a candidate list where nothing can run */
+ $unavailable = new UnavailableCalculatorMock();
+
+ /** @Then a failure describing the empty resolution is raised */
+ $this->expectException(CalculatorNotAvailable::class);
+
+ /** @When the selection resolves over it */
+ new CalculatorSelection()->resolved($unavailable);
+ }
+
+ public function testResolutionWhenBcMathIsMissingThenFallsBackToThePurePhpBackend(): void
+ {
+ /** @Given a candidate list whose first backend cannot run in this process */
+ $unavailable = new UnavailableCalculatorMock();
+
+ /** @When the selection resolves over it and the pure PHP backend */
+ $actual = new CalculatorSelection()->resolved($unavailable, new NativeCalculator());
+
+ /** @Then the pure PHP backend is taken, so the extension is not required */
+ self::assertInstanceOf(NativeCalculator::class, $actual);
+ }
+
+ public static function rootProvider(): array
+ {
+ return [
+ 'Zero' => ['radicand' => '0', 'expected' => '0'],
+ 'One' => ['radicand' => '1', 'expected' => '1'],
+ 'Below a square' => ['radicand' => '8', 'expected' => '2'],
+ 'Exact square' => ['radicand' => '9', 'expected' => '3'],
+ 'Single limb' => ['radicand' => '999999999', 'expected' => '31622'],
+ 'Across limbs' => ['radicand' => '12345678901234567890123456789', 'expected' => '111111110611111']
+ ];
+ }
+
+ public static function powerProvider(): array
+ {
+ return [
+ 'Zero exponent' => ['base' => '7', 'exponent' => 0, 'expected' => '1'],
+ 'One exponent' => ['base' => '-7', 'exponent' => 1, 'expected' => '-7'],
+ 'Even exponent' => ['base' => '-3', 'exponent' => 4, 'expected' => '81'],
+ 'Odd exponent' => ['base' => '-3', 'exponent' => 5, 'expected' => '-243'],
+ 'Across limbs' => [
+ 'base' => '1000000000',
+ 'exponent' => 3,
+ 'expected' => '1000000000000000000000000000'
+ ],
+ 'Zero base' => ['base' => '0', 'exponent' => 5, 'expected' => '0']
+ ];
+ }
+
+ public static function productProvider(): array
+ {
+ return [
+ 'By zero' => ['left' => '12345678901234567890', 'right' => '0', 'expected' => '0'],
+ 'Zero by value' => ['left' => '0', 'right' => '12345678901234567890', 'expected' => '0'],
+ 'Sign flips' => ['left' => '-7', 'right' => '6', 'expected' => '-42'],
+ 'Both negative' => ['left' => '-7', 'right' => '-6', 'expected' => '42'],
+ 'Limb boundary' => ['left' => '1000000000', 'right' => '1000000000', 'expected' => '1000000000000000000'],
+ 'Full carry' => [
+ 'left' => '99999999999999999999',
+ 'right' => '99999999999999999999',
+ 'expected' => '9999999999999999999800000000000000000001'
+ ]
+ ];
+ }
+
+ public static function additionProvider(): array
+ {
+ return [
+ 'Zero and zero' => ['left' => '0', 'right' => '0', 'expected' => '0'],
+ 'Carry across limbs' => ['left' => '999999999', 'right' => '1', 'expected' => '1000000000'],
+ 'Opposite signs' => ['left' => '1000000000', 'right' => '-1', 'expected' => '999999999'],
+ 'Both negative' => ['left' => '-999999999', 'right' => '-1', 'expected' => '-1000000000'],
+ 'Cancelling' => [
+ 'left' => '-12345678901234567890',
+ 'right' => '12345678901234567890',
+ 'expected' => '0'
+ ],
+ 'Many limbs' => [
+ 'left' => '999999999999999999999999999',
+ 'right' => '1',
+ 'expected' => '1000000000000000000000000000'
+ ]
+ ];
+ }
+
+ public static function divisionProvider(): array
+ {
+ return [
+ 'Exact' => [
+ 'numerator' => '100',
+ 'denominator' => '5',
+ 'quotient' => '20',
+ 'remainder' => '0'
+ ],
+ 'Truncates toward zero' => [
+ 'numerator' => '7',
+ 'denominator' => '2',
+ 'quotient' => '3',
+ 'remainder' => '1'
+ ],
+ 'Negative dividend' => [
+ 'numerator' => '-7',
+ 'denominator' => '2',
+ 'quotient' => '-3',
+ 'remainder' => '-1'
+ ],
+ 'Negative divisor' => [
+ 'numerator' => '7',
+ 'denominator' => '-2',
+ 'quotient' => '-3',
+ 'remainder' => '1'
+ ],
+ 'Both negative' => [
+ 'numerator' => '-7',
+ 'denominator' => '-2',
+ 'quotient' => '3',
+ 'remainder' => '-1'
+ ],
+ 'Divisor is larger' => [
+ 'numerator' => '5',
+ 'denominator' => '1000000000000',
+ 'quotient' => '0',
+ 'remainder' => '5'
+ ],
+ 'Across limbs' => [
+ 'numerator' => '12345678901234567890123456789',
+ 'denominator' => '987654321',
+ 'quotient' => '12499999887343749990',
+ 'remainder' => '156249999'
+ ]
+ ];
+ }
+
+ public static function comparisonProvider(): array
+ {
+ return [
+ 'Equal' => ['left' => '0', 'right' => '0', 'expected' => 0],
+ 'Equal across limbs' => ['left' => '1000000000', 'right' => '1000000000', 'expected' => 0],
+ 'Negative to zero' => ['left' => '-1', 'right' => '0', 'expected' => -1],
+ 'Zero to negative' => ['left' => '0', 'right' => '-1', 'expected' => 1],
+ 'Both negative' => ['left' => '-2', 'right' => '-1', 'expected' => -1],
+ 'Limb count decides' => ['left' => '1000000000', 'right' => '999999999', 'expected' => 1],
+ 'Deep limb decides' => [
+ 'left' => '1000000000000000000000000001',
+ 'right' => '1000000000000000000000000000',
+ 'expected' => 1
+ ]
+ ];
+ }
+
+ public static function subtractionProvider(): array
+ {
+ return [
+ 'Borrow across limbs' => ['left' => '1000000000', 'right' => '1', 'expected' => '999999999'],
+ 'To zero' => ['left' => '42', 'right' => '42', 'expected' => '0'],
+ 'Below zero' => ['left' => '1', 'right' => '1000000000', 'expected' => '-999999999'],
+ 'Negative minuend' => ['left' => '-1000000000', 'right' => '1', 'expected' => '-1000000001'],
+ 'Double negative' => ['left' => '-1000000000', 'right' => '-1', 'expected' => '-999999999'],
+ 'Many limbs' => [
+ 'left' => '1000000000000000000000000000',
+ 'right' => '1',
+ 'expected' => '999999999999999999999999999'
+ ]
+ ];
+ }
+}
diff --git a/tests/Unit/PercentageTest.php b/tests/Unit/PercentageTest.php
new file mode 100644
index 0000000..2625349
--- /dev/null
+++ b/tests/Unit/PercentageTest.php
@@ -0,0 +1,268 @@
+toString());
+ }
+
+ #[DataProvider('applicationProvider')]
+ public function testApplyToThenStaysExact(
+ string $value,
+ string $amount,
+ string $applied,
+ string $increased,
+ string $decreased
+ ): void {
+ /** @Given a percentage, an amount, and the three expected exact results */
+
+ /** @When the percentage is applied to the amount */
+ $actual = Percentage::of(value: $value);
+
+ /** @Then every result is exact, with no rounding decision forced on the caller */
+ self::assertSame($applied, $actual->applyTo(amount: BigDecimal::of(value: $amount))->toString());
+ self::assertSame($increased, $actual->increase(amount: BigDecimal::of(value: $amount))->toString());
+ self::assertSame($decreased, $actual->decrease(amount: BigDecimal::of(value: $amount))->toString());
+ }
+
+ public function testToRatioThenReducesToLowestTerms(): void
+ {
+ /** @Given a quarter expressed as a percentage */
+ $percentage = Percentage::of(value: '25');
+
+ /** @When it is converted to a ratio */
+ $actual = $percentage->toRatio();
+
+ /** @Then the ratio is in lowest terms */
+ self::assertSame('1:4', $actual->toString());
+ }
+
+ #[DataProvider('signProvider')]
+ public function testSignPredicatesThenAgreeOnTheSign(
+ string $value,
+ bool $isZero,
+ bool $isNegative,
+ bool $isPositive
+ ): void {
+ /** @Given a percentage literal and its expected sign */
+
+ /** @When the sign predicates are asked */
+ $actual = Percentage::of(value: $value);
+
+ /** @Then all three agree, and a rate above one hundred stays legal */
+ self::assertSame($isZero, $actual->isZero());
+ self::assertSame($isNegative, $actual->isNegative());
+ self::assertSame($isPositive, $actual->isPositive());
+ }
+
+ #[DataProvider('rateProvider')]
+ public function testRateThenDividesTheValueByOneHundred(string|int $value, string $expected): void
+ {
+ /** @Given a percentage literal and the factor it multiplies by */
+
+ /** @When its rate is taken */
+ $actual = Percentage::of(value: $value)->rate();
+
+ /** @Then the factor carries two more fractional digits */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ #[DataProvider('comparisonProvider')]
+ public function testComparisonPredicatesThenIgnoreTheScale(string $value, string $other, int $expected): void
+ {
+ /** @Given a percentage literal */
+ $rate = Percentage::of(value: $value);
+
+ /** @And another to compare it against */
+ $against = Percentage::of(value: $other);
+
+ /** @When they are compared for equality */
+ $actual = $rate->isEqualTo(other: $against);
+
+ /** @Then every predicate agrees, and the scale plays no part */
+ self::assertSame($expected === 0, $actual);
+ self::assertSame($expected < 0, $rate->isLessThan(other: $against));
+ self::assertSame($expected > 0, $rate->isGreaterThan(other: $against));
+ self::assertSame($expected <= 0, $rate->isLessThanOrEqualTo(other: $against));
+ self::assertSame($expected >= 0, $rate->isGreaterThanOrEqualTo(other: $against));
+ }
+
+ public function testEqualsWhenScalesDifferThenTheyAreNotEqual(): void
+ {
+ /** @Given a percentage at scale zero */
+ $percentage = Percentage::of(value: '10');
+
+ /** @And the same rate at scale one */
+ $other = Percentage::of(value: '10.0');
+
+ /** @When they are compared structurally */
+ $actual = $percentage->equals(other: $other);
+
+ /** @Then they are not equal, because the scale is part of the representation */
+ self::assertFalse($actual);
+ }
+
+ public function testHashCodeWhenPercentagesMatchThenHashesMatch(): void
+ {
+ /** @Given a percentage */
+ $percentage = Percentage::of(value: '12.5');
+
+ /** @And another percentage holding the same rate and scale */
+ $other = Percentage::of(value: '12.5');
+
+ /** @When both hashes are taken */
+ $actual = $percentage->hashCode();
+
+ /** @Then they agree */
+ self::assertSame($other->hashCode(), $actual);
+ }
+
+ public function testEqualsWhenValueAndScaleMatchThenTheyAreEqual(): void
+ {
+ /** @Given a percentage */
+ $percentage = Percentage::of(value: '12.5');
+
+ /** @And another percentage holding the same value and scale */
+ $other = Percentage::of(value: '12.5');
+
+ /** @When they are compared structurally */
+ $actual = $percentage->equals(other: $other);
+
+ /** @Then they are equal */
+ self::assertTrue($actual);
+ }
+
+ public function testFromRatioThenExpressesTheProportionPerHundred(): void
+ {
+ /** @Given a proportion of one to four */
+ $ratio = Ratio::of(antecedent: 1, consequent: 4);
+
+ /** @When it is expressed as a percentage */
+ $actual = Percentage::fromRatio(ratio: $ratio, scale: 0, rounding: RoundingMode::HalfEven);
+
+ /** @Then it reads as twenty-five percent */
+ self::assertSame('25%', $actual->toString());
+ }
+
+ public function testJsonSerializeThenEmitsAJsonStringThatReadsBack(): void
+ {
+ /** @Given a percentage */
+ $percentage = Percentage::of(value: '12.5');
+
+ /** @When it is encoded */
+ $actual = json_encode($percentage);
+
+ /** @Then it is a JSON string carrying the percent sign, and it round-trips */
+ self::assertSame('"12.5%"', $actual);
+ self::assertTrue(Percentage::of(value: json_decode((string)$actual))->equals(other: $percentage));
+ }
+
+ public function testOfWhenLiteralIsMalformedThenNumberNotWellFormed(): void
+ {
+ /** @Given a literal that is not a number */
+ $value = 'twelve';
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When a percentage is created from it */
+ Percentage::of(value: $value);
+ }
+
+ public function testFromRatioWhenProportionRepeatsThenRoundsAtTheScale(): void
+ {
+ /** @Given a proportion whose percentage repeats forever */
+ $ratio = Ratio::of(antecedent: 1, consequent: 3);
+
+ /** @When it is expressed at scale two */
+ $actual = Percentage::fromRatio(ratio: $ratio, scale: 2, rounding: RoundingMode::HalfEven);
+
+ /** @Then it is rounded at that scale */
+ self::assertSame('33.33%', $actual->toString());
+ }
+
+ public static function rateProvider(): array
+ {
+ return [
+ 'Whole' => ['value' => '10', 'expected' => '0.10'],
+ 'Fractional' => ['value' => '12.5', 'expected' => '0.125'],
+ 'Native integer' => ['value' => 25, 'expected' => '0.25'],
+ 'Hundred' => ['value' => '100', 'expected' => '1.00'],
+ 'Above hundred' => ['value' => '150', 'expected' => '1.50'],
+ 'Negative' => ['value' => '-5', 'expected' => '-0.05'],
+ 'Zero' => ['value' => '0', 'expected' => '0.00']
+ ];
+ }
+
+ public static function signProvider(): array
+ {
+ return [
+ 'Zero' => ['value' => '0', 'isZero' => true, 'isNegative' => false, 'isPositive' => false],
+ 'Negative' => ['value' => '-5', 'isZero' => false, 'isNegative' => true, 'isPositive' => false],
+ 'Positive' => ['value' => '5', 'isZero' => false, 'isNegative' => false, 'isPositive' => true],
+ 'Above hundred' => ['value' => '150', 'isZero' => false, 'isNegative' => false, 'isPositive' => true]
+ ];
+ }
+
+ public static function comparisonProvider(): array
+ {
+ return [
+ 'Less than' => ['value' => '10', 'other' => '12.5', 'expected' => -1],
+ 'Equal' => ['value' => '10', 'other' => '10.00', 'expected' => 0],
+ 'Greater than' => ['value' => '12.5', 'other' => '10', 'expected' => 1]
+ ];
+ }
+
+ public static function applicationProvider(): array
+ {
+ return [
+ 'Discount' => [
+ 'value' => '12.5',
+ 'amount' => '19.99',
+ 'applied' => '2.49875',
+ 'increased' => '22.48875',
+ 'decreased' => '17.49125'
+ ],
+ 'Whole rate' => [
+ 'value' => '10',
+ 'amount' => '100.00',
+ 'applied' => '10.0000',
+ 'increased' => '110.0000',
+ 'decreased' => '90.0000'
+ ],
+ 'Negative rate' => [
+ 'value' => '-10',
+ 'amount' => '100.00',
+ 'applied' => '-10.0000',
+ 'increased' => '90.0000',
+ 'decreased' => '110.0000'
+ ],
+ 'Zero rate' => [
+ 'value' => '0',
+ 'amount' => '100.00',
+ 'applied' => '0.0000',
+ 'increased' => '100.0000',
+ 'decreased' => '100.0000'
+ ]
+ ];
+ }
+}
diff --git a/tests/Unit/RatioTest.php b/tests/Unit/RatioTest.php
new file mode 100644
index 0000000..3b99542
--- /dev/null
+++ b/tests/Unit/RatioTest.php
@@ -0,0 +1,279 @@
+applyTo(amount: BigDecimal::of(value: $amount));
+
+ /** @Then the exact fraction is returned, with no rounding decision forced */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ public function testInvertedThenSwapsTheTerms(): void
+ {
+ /** @Given a ratio */
+ $ratio = Ratio::of(antecedent: 16, consequent: 9);
+
+ /** @When it is inverted */
+ $actual = $ratio->inverted();
+
+ /** @Then the terms are swapped */
+ self::assertSame('9:16', $actual->toString());
+ }
+
+ #[DataProvider('termProvider')]
+ public function testOfThenReducesToLowestTerms(
+ int|string $antecedent,
+ int|string $consequent,
+ string $expected,
+ string $first,
+ string $second
+ ): void {
+ /** @Given two terms with the reduced ratio they stand for */
+
+ /** @When a ratio is created */
+ $actual = Ratio::of(antecedent: $antecedent, consequent: $consequent);
+
+ /** @Then the ratio is reduced and both terms read back */
+ self::assertSame($expected, $actual->toString());
+ self::assertSame($first, $actual->antecedent()->toString());
+ self::assertSame($second, $actual->consequent()->toString());
+ }
+
+ public function testBetweenThenStaysExactWithoutAScale(): void
+ {
+ /** @Given a quantity */
+ $antecedent = BigDecimal::of(value: '1.5');
+
+ /** @And a second quantity three times as large */
+ $consequent = BigDecimal::of(value: '4.5');
+
+ /** @When the proportion between them is taken */
+ $actual = Ratio::between(antecedent: $antecedent, consequent: $consequent);
+
+ /** @Then the proportion is exact and reduced, with no rounding involved */
+ self::assertSame('1:3', $actual->toString());
+ }
+
+ public function testToBigRationalThenExposesTheProportion(): void
+ {
+ /** @Given a ratio */
+ $ratio = Ratio::of(antecedent: 16, consequent: 9);
+
+ /** @When its proportion is taken */
+ $actual = $ratio->toBigRational();
+
+ /** @Then it reads as the equivalent fraction */
+ self::assertSame('16/9', $actual->toString());
+ }
+
+ public function testOfWhenConsequentIsZeroThenDivisionByZero(): void
+ {
+ /** @Given a consequent of zero */
+ $consequent = 0;
+
+ /** @Then a failure describing the zero denominator is raised */
+ $this->expectException(DivisionByZero::class);
+
+ /** @When a ratio is created */
+ Ratio::of(antecedent: 1, consequent: $consequent);
+ }
+
+ #[DataProvider('percentageProvider')]
+ public function testToPercentageThenRoundsAtTheRequestedScale(
+ int $antecedent,
+ int $consequent,
+ int $scale,
+ string $expected
+ ): void {
+ /** @Given a ratio, a target scale, and the expected percentage */
+
+ /** @When it is expressed per hundred */
+ $actual = Ratio::of(antecedent: $antecedent, consequent: $consequent);
+
+ /** @Then the percentage is rounded at that scale */
+ self::assertSame(
+ $expected,
+ $actual->toPercentage(scale: $scale, rounding: RoundingMode::HalfEven)->toString()
+ );
+ }
+
+ public function testEqualsWhenProportionsMatchThenTheyAreEqual(): void
+ {
+ /** @Given a ratio */
+ $ratio = Ratio::of(antecedent: 16, consequent: 9);
+
+ /** @And the same proportion written unreduced */
+ $other = Ratio::of(antecedent: 32, consequent: 18);
+
+ /** @When they are compared structurally */
+ $actual = $ratio->equals(other: $other);
+
+ /** @Then they are equal, because a ratio is always stored in lowest terms */
+ self::assertTrue($actual);
+ }
+
+ public function testHashCodeWhenProportionsMatchThenHashesMatch(): void
+ {
+ /** @Given a ratio */
+ $ratio = Ratio::of(antecedent: 16, consequent: 9);
+
+ /** @And the same proportion written unreduced */
+ $other = Ratio::of(antecedent: 32, consequent: 18);
+
+ /** @When both hashes are taken */
+ $actual = $ratio->hashCode();
+
+ /** @Then they agree */
+ self::assertSame($other->hashCode(), $actual);
+ }
+
+ public function testBetweenWhenConsequentIsZeroThenDivisionByZero(): void
+ {
+ /** @Given a quantity */
+ $antecedent = BigInteger::of(value: 1);
+
+ /** @Then a failure describing the zero divisor is raised */
+ $this->expectException(DivisionByZero::class);
+
+ /** @When the proportion against zero is taken */
+ Ratio::between(antecedent: $antecedent, consequent: BigInteger::zero());
+ }
+
+ public function testInvertedWhenAntecedentIsZeroThenDivisionByZero(): void
+ {
+ /** @Given a ratio whose antecedent is zero */
+ $ratio = Ratio::of(antecedent: 0, consequent: 5);
+
+ /** @Then a failure describing the undefined reciprocal is raised */
+ $this->expectException(DivisionByZero::class);
+
+ /** @When it is inverted */
+ $ratio->inverted();
+ }
+
+ public function testJsonSerializeThenEmitsAJsonStringThatReadsBack(): void
+ {
+ /** @Given a ratio */
+ $ratio = Ratio::of(antecedent: 16, consequent: 9);
+
+ /** @When it is encoded */
+ $actual = json_encode($ratio);
+
+ /** @Then it is a JSON string carrying the colon, and it round-trips */
+ self::assertSame('"16:9"', $actual);
+ self::assertTrue(Ratio::from(value: json_decode((string)$actual))->equals(other: $ratio));
+ }
+
+ public function testOfWhenTermCarriesAFractionalPartThenInexactConversion(): void
+ {
+ /** @Given a term with a non-zero fractional part */
+ $antecedent = '1.5';
+
+ /** @Then a failure describing the lost fractional part is raised */
+ $this->expectException(InexactConversion::class);
+
+ /** @When a ratio is created */
+ Ratio::of(antecedent: $antecedent, consequent: 2);
+ }
+
+ public function testFromWhenTheStringDoesNotCarryTwoTermsThenNumberNotWellFormed(): void
+ {
+ /** @Given a string carrying three terms */
+ $value = '16:9:4';
+
+ /** @Then a failure describing the malformed literal is raised */
+ $this->expectException(NumberNotWellFormed::class);
+
+ /** @When a ratio is created from it */
+ Ratio::from(value: $value);
+ }
+
+ public static function termProvider(): array
+ {
+ return [
+ 'Already reduced' => [
+ 'antecedent' => 16,
+ 'consequent' => 9,
+ 'expected' => '16:9',
+ 'first' => '16',
+ 'second' => '9'
+ ],
+ 'Reducible' => [
+ 'antecedent' => 4,
+ 'consequent' => 2,
+ 'expected' => '2:1',
+ 'first' => '2',
+ 'second' => '1'
+ ],
+ 'Negative antecedent' => [
+ 'antecedent' => -1,
+ 'consequent' => 2,
+ 'expected' => '-1:2',
+ 'first' => '-1',
+ 'second' => '2'
+ ],
+ 'Negative consequent' => [
+ 'antecedent' => 1,
+ 'consequent' => -2,
+ 'expected' => '-1:2',
+ 'first' => '-1',
+ 'second' => '2'
+ ],
+ 'Zero antecedent' => [
+ 'antecedent' => 0,
+ 'consequent' => 5,
+ 'expected' => '0:1',
+ 'first' => '0',
+ 'second' => '1'
+ ],
+ 'String terms' => [
+ 'antecedent' => '1000000000000000000000',
+ 'consequent' => '2000000000000000000000',
+ 'expected' => '1:2',
+ 'first' => '1',
+ 'second' => '2'
+ ]
+ ];
+ }
+
+ public static function percentageProvider(): array
+ {
+ return [
+ 'Quarter' => ['antecedent' => 1, 'consequent' => 4, 'scale' => 0, 'expected' => '25%'],
+ 'Third' => ['antecedent' => 1, 'consequent' => 3, 'scale' => 2, 'expected' => '33.33%'],
+ 'Whole' => ['antecedent' => 1, 'consequent' => 1, 'scale' => 0, 'expected' => '100%'],
+ 'Above one' => ['antecedent' => 3, 'consequent' => 2, 'scale' => 1, 'expected' => '150.0%']
+ ];
+ }
+
+ public static function applicationProvider(): array
+ {
+ return [
+ 'Exact third' => ['antecedent' => 1, 'consequent' => 3, 'amount' => '90.00', 'expected' => '30'],
+ 'Repeating' => ['antecedent' => 1, 'consequent' => 3, 'amount' => '100.00', 'expected' => '100/3'],
+ 'Scaling up' => ['antecedent' => 16, 'consequent' => 9, 'amount' => '9.00', 'expected' => '16'],
+ 'Negative term' => ['antecedent' => -1, 'consequent' => 2, 'amount' => '10.00', 'expected' => '-5']
+ ];
+ }
+}
diff --git a/tests/Unit/RoundingModeTest.php b/tests/Unit/RoundingModeTest.php
new file mode 100644
index 0000000..16ebf2f
--- /dev/null
+++ b/tests/Unit/RoundingModeTest.php
@@ -0,0 +1,84 @@
+toNativeRoundingMode();
+
+ /** @Then the native case is returned */
+ self::assertSame($expected, $actual);
+ }
+
+ #[DataProvider('nativeEquivalentProvider')]
+ public function testFromNativeRoundingModeThenMatchesTheLibraryCase(
+ RoundingMode $mode,
+ NativeRoundingMode $expected
+ ): void {
+ /** @Given a library rounding mode and the native case it stands for */
+
+ /** @When the library equivalent of the native case is requested */
+ $actual = RoundingMode::fromNativeRoundingMode(mode: $expected);
+
+ /** @Then the library case is returned */
+ self::assertSame($mode, $actual);
+ }
+
+ #[DataProvider('halfwayValueProvider')]
+ public function testRoundingWhenTheDiscardedPartIsExactlyHalfThenEachModeDiffers(
+ RoundingMode $mode,
+ string $expected
+ ): void {
+ /** @Given a value whose discarded part at scale two is exactly half */
+
+ /** @When it is taken to scale two under the mode */
+ $actual = BigDecimal::of(value: '-2.345')->toScale(scale: 2, rounding: $mode);
+
+ /** @Then the mode decides the last digit */
+ self::assertSame($expected, $actual->toString());
+ }
+
+ public static function halfwayValueProvider(): array
+ {
+ return [
+ 'Away from zero' => ['mode' => RoundingMode::Up, 'expected' => '-2.35'],
+ 'Toward zero' => ['mode' => RoundingMode::Down, 'expected' => '-2.34'],
+ 'Toward negative' => ['mode' => RoundingMode::Floor, 'expected' => '-2.35'],
+ 'Half away from zero' => ['mode' => RoundingMode::HalfUp, 'expected' => '-2.35'],
+ 'Toward positive' => ['mode' => RoundingMode::Ceiling, 'expected' => '-2.34'],
+ 'Half to odd' => ['mode' => RoundingMode::HalfOdd, 'expected' => '-2.35'],
+ 'Half toward zero' => ['mode' => RoundingMode::HalfDown, 'expected' => '-2.34'],
+ 'Half to even' => ['mode' => RoundingMode::HalfEven, 'expected' => '-2.34']
+ ];
+ }
+
+ public static function nativeEquivalentProvider(): array
+ {
+ return [
+ 'Up' => ['mode' => RoundingMode::Up, 'expected' => NativeRoundingMode::AwayFromZero],
+ 'Down' => ['mode' => RoundingMode::Down, 'expected' => NativeRoundingMode::TowardsZero],
+ 'Floor' => ['mode' => RoundingMode::Floor, 'expected' => NativeRoundingMode::NegativeInfinity],
+ 'HalfUp' => ['mode' => RoundingMode::HalfUp, 'expected' => NativeRoundingMode::HalfAwayFromZero],
+ 'Ceiling' => ['mode' => RoundingMode::Ceiling, 'expected' => NativeRoundingMode::PositiveInfinity],
+ 'HalfOdd' => ['mode' => RoundingMode::HalfOdd, 'expected' => NativeRoundingMode::HalfOdd],
+ 'HalfDown' => ['mode' => RoundingMode::HalfDown, 'expected' => NativeRoundingMode::HalfTowardsZero],
+ 'HalfEven' => ['mode' => RoundingMode::HalfEven, 'expected' => NativeRoundingMode::HalfEven]
+ ];
+ }
+}
diff --git a/tests/Unit/UnavailableCalculatorMock.php b/tests/Unit/UnavailableCalculatorMock.php
new file mode 100644
index 0000000..a75d1cb
--- /dev/null
+++ b/tests/Unit/UnavailableCalculatorMock.php
@@ -0,0 +1,55 @@
+