diff --git a/composer.json b/composer.json index 79b7c35..9db8a3f 100644 --- a/composer.json +++ b/composer.json @@ -6,8 +6,8 @@ "require": { "php": "^8.4", "ext-intl": "*", - "gosuperscript/axiom": "^0.6.0", - "superscript/interval": "^1.0.4" + "gosuperscript/axiom": "^0.6.9", + "superscript/interval": "^2.0" }, "require-dev": { "infection/infection": "^0.29.14", diff --git a/src/IntervalExtension.php b/src/IntervalExtension.php index 07788ba..1dc89f8 100644 --- a/src/IntervalExtension.php +++ b/src/IntervalExtension.php @@ -44,13 +44,13 @@ public function operators(): array return [ Operator::infix('<')->takes($interval, $number)->returns($boolean) - ->evaluatesWith(fn(Interval $left, int|float $right) => $left->isLessThan($right)), + ->evaluatesWith(fn(Interval $left, int|float $right) => $left->isLessThan(self::exact($right))), Operator::infix('<=')->takes($interval, $number)->returns($boolean) - ->evaluatesWith(fn(Interval $left, int|float $right) => $left->isLessThanOrEqualTo($right)), + ->evaluatesWith(fn(Interval $left, int|float $right) => $left->isLessThanOrEqualTo(self::exact($right))), Operator::infix('>')->takes($interval, $number)->returns($boolean) - ->evaluatesWith(fn(Interval $left, int|float $right) => $left->isGreaterThan($right)), + ->evaluatesWith(fn(Interval $left, int|float $right) => $left->isGreaterThan(self::exact($right))), Operator::infix('>=')->takes($interval, $number)->returns($boolean) - ->evaluatesWith(fn(Interval $left, int|float $right) => $left->isGreaterThanOrEqualTo($right)), + ->evaluatesWith(fn(Interval $left, int|float $right) => $left->isGreaterThanOrEqualTo(self::exact($right))), Operator::infix('=')->takes($interval, $interval)->returns($boolean) ->evaluatesWith(fn(Interval $left, Interval $right) => $left->isEqualTo($right)), @@ -72,4 +72,25 @@ public function literals(): array { return [Interval::class => fn() => new IntervalType()]; } + + /** + * Renders a host scalar in the form an interval bound can be compared against. + * + * brick rejects floats throughout its arithmetic, because a binary float carries no exact + * decimal value to compare with: the literal 0.1 is not one tenth. A float is therefore spelled + * as the shortest decimal that round-trips back to it, so the comparison sees the number the + * host actually holds. + * + * That spelling is `json_encode`'s, because it honours `serialize_precision` (-1). A `(string)` + * cast honours `precision` instead — 14 significant digits by default — so it truncates and can + * emit exponent notation: `(string) 123456789012345.67` gives `1.2345678901235E+14`, which sits + * on the wrong side of a bound between the two. Ints are already exact. + * + * A non-finite float names no decimal at all; JSON_THROW_ON_ERROR refuses it rather than let a + * meaningless bound through. + */ + private static function exact(int|float $value): string|int + { + return is_float($value) ? json_encode($value, JSON_THROW_ON_ERROR) : $value; + } } diff --git a/src/Types/IntervalType.php b/src/Types/IntervalType.php index ede0af3..fcb56ca 100644 --- a/src/Types/IntervalType.php +++ b/src/Types/IntervalType.php @@ -41,6 +41,10 @@ public function assert(mixed $value): Result public function format(mixed $value): string { + if (! $value instanceof Interval) { + throw new TransformValueException(type: 'interval', value: $value); + } + return (string) $value; } diff --git a/tests/IntervalExtensionTest.php b/tests/IntervalExtensionTest.php index d50f2b5..f80e6ad 100644 --- a/tests/IntervalExtensionTest.php +++ b/tests/IntervalExtensionTest.php @@ -64,6 +64,16 @@ public static function comparisons(): Generator yield '[2,3] < 2' => ['[2,3]', '<', 2, false]; yield '[2,3] < 4' => ['[2,3]', '<', 4, true]; yield '[2,3] <= 3' => ['[2,3]', '<=', 3, true]; + yield '[2,3] > 1.5' => ['[2,3]', '>', 1.5, true]; + yield '[2,3] < 3.5' => ['[2,3]', '<', 3.5, true]; + // Past 14 significant digits a (string) cast reads 123456789012345.67 as + // 1.2345678901235E+14 — 123456789012350 — which lands above this bound and flips the answer. + yield 'a bound between the float and its truncation' => [ + '[123456789012346,123456789012400]', + '>', + 123456789012345.67, + true, + ]; } #[Test] diff --git a/tests/Types/IntervalTypeTest.php b/tests/Types/IntervalTypeTest.php index 23844db..f1eba00 100644 --- a/tests/Types/IntervalTypeTest.php +++ b/tests/Types/IntervalTypeTest.php @@ -92,4 +92,12 @@ public static function formatProvider(): array ['(1,2)', '(1,2)'], ]; } + + #[Test] + public function it_refuses_to_format_a_value_that_is_not_an_interval(): void + { + $this->expectException(TransformValueException::class); + + (new IntervalType())->format('[1,2]'); + } }