diff --git a/src/Concerns/Extendable.php b/src/Concerns/Extendable.php index 5cf3e79d..6f83693a 100644 --- a/src/Concerns/Extendable.php +++ b/src/Concerns/Extendable.php @@ -19,6 +19,9 @@ trait Extendable */ private static array $extends = []; + /** @var array> */ + private static array $pipes = []; + /** * Register a new extend. */ @@ -27,6 +30,40 @@ trait Extendable static::$extends[$name] = $extend; } + /** + * Register a pipe to be applied before an expectation is checked. + */ + public static function pipe(string $name, Closure $pipe): void + { + self::$pipes[$name][] = $pipe; + } + + /** + * Register an interceptor that should replace an existing expectation. + * + * @param string|Closure(mixed $value, mixed ...$arguments):bool $filter + */ + public static function intercept(string $name, string|Closure $filter, Closure $handler): void + { + if (is_string($filter)) { + $filter = function ($value) use ($filter): bool { + return $value instanceof $filter; + }; + } + + self::pipe($name, function ($next, ...$arguments) use ($handler, $filter) { + /* @phpstan-ignore-next-line */ + if ($filter($this->value, ...$arguments)) { + //@phpstan-ignore-next-line + $handler->bindTo($this, get_class($this))(...$arguments); + + return; + } + + $next(); + }); + } + /** * Checks if given extend name is registered. */ @@ -35,6 +72,14 @@ trait Extendable return array_key_exists($name, static::$extends); } + /** + * @return array + */ + private function pipes(string $name, object $context, string $scope): array + { + return array_map(fn (Closure $pipe) => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []); + } + /** * Dynamically handle calls to the class. * diff --git a/src/CoreExpectation.php b/src/CoreExpectation.php new file mode 100644 index 00000000..153a935d --- /dev/null +++ b/src/CoreExpectation.php @@ -0,0 +1,863 @@ + + */ +final class CoreExpectation +{ + use RetrievesValues; + + /** + * The exporter instance, if any. + * + * @readonly + */ + private Exporter|null $exporter = null; + + /** + * Creates a new expectation. + * + * @param TValue $value + */ + public function __construct( + public mixed $value + ) { + // .. + } + + /** + * Asserts that two variables have the same type and + * value. Used on objects, it asserts that two + * variables reference the same object. + * + * @return CoreExpectation + */ + public function toBe(mixed $expected): CoreExpectation + { + Assert::assertSame($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is empty. + * + * @return CoreExpectation + */ + public function toBeEmpty(): CoreExpectation + { + Assert::assertEmpty($this->value); + + return $this; + } + + /** + * Asserts that the value is true. + * + * @return CoreExpectation + */ + public function toBeTrue(): CoreExpectation + { + Assert::assertTrue($this->value); + + return $this; + } + + /** + * Asserts that the value is truthy. + * + * @return CoreExpectation + */ + public function toBeTruthy(): CoreExpectation + { + Assert::assertTrue((bool) $this->value); + + return $this; + } + + /** + * Asserts that the value is false. + * + * @return CoreExpectation + */ + public function toBeFalse(): CoreExpectation + { + Assert::assertFalse($this->value); + + return $this; + } + + /** + * Asserts that the value is falsy. + * + * @return CoreExpectation + */ + public function toBeFalsy(): CoreExpectation + { + Assert::assertFalse((bool) $this->value); + + return $this; + } + + /** + * Asserts that the value is greater than $expected. + * + * @return CoreExpectation + */ + public function toBeGreaterThan(int|float $expected): CoreExpectation + { + Assert::assertGreaterThan($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is greater than or equal to $expected. + * + * @return CoreExpectation + */ + public function toBeGreaterThanOrEqual(int|float $expected): CoreExpectation + { + Assert::assertGreaterThanOrEqual($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is less than or equal to $expected. + * + * @return CoreExpectation + */ + public function toBeLessThan(int|float $expected): CoreExpectation + { + Assert::assertLessThan($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is less than $expected. + * + * @return CoreExpectation + */ + public function toBeLessThanOrEqual(int|float $expected): CoreExpectation + { + Assert::assertLessThanOrEqual($expected, $this->value); + + return $this; + } + + /** + * Asserts that $needle is an element of the value. + * + * @return CoreExpectation + */ + public function toContain(mixed ...$needles): CoreExpectation + { + foreach ($needles as $needle) { + if (is_string($this->value)) { + // @phpstan-ignore-next-line + Assert::assertStringContainsString((string) $needle, $this->value); + } else { + if (!is_iterable($this->value)) { + InvalidExpectationValue::expected('iterable'); + } + Assert::assertContains($needle, $this->value); + } + } + + return $this; + } + + /** + * Asserts that the value starts with $expected. + * + * @return CoreExpectation + * + * @param non-empty-string $expected + */ + public function toStartWith(string $expected): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + + Assert::assertStringStartsWith($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value ends with $expected. + * + * @return CoreExpectation + * + * @param non-empty-string $expected + */ + public function toEndWith(string $expected): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + + Assert::assertStringEndsWith($expected, $this->value); + + return $this; + } + + /** + * Asserts that $number matches value's Length. + * + * @return CoreExpectation + */ + public function toHaveLength(int $number): CoreExpectation + { + if (is_string($this->value)) { + Assert::assertEquals($number, mb_strlen($this->value)); + + return $this; + } + + if (is_iterable($this->value)) { + return $this->toHaveCount($number); + } + + if (is_object($this->value)) { + if (method_exists($this->value, 'toArray')) { + $array = $this->value->toArray(); + } else { + $array = (array) $this->value; + } + + Assert::assertCount($number, $array); + + return $this; + } + + throw new BadMethodCallException('Expectation value length is not countable.'); + } + + /** + * Asserts that $count matches the number of elements of the value. + * + * @return CoreExpectation + */ + public function toHaveCount(int $count): CoreExpectation + { + if (!is_countable($this->value) && !is_iterable($this->value)) { + InvalidExpectationValue::expected('string'); + } + + Assert::assertCount($count, $this->value); + + return $this; + } + + /** + * Asserts that the value contains the property $name. + * + * @return CoreExpectation + */ + public function toHaveProperty(string $name, mixed $value = null): CoreExpectation + { + $this->toBeObject(); + + //@phpstan-ignore-next-line + Assert::assertTrue(property_exists($this->value, $name)); + + if (func_num_args() > 1) { + /* @phpstan-ignore-next-line */ + Assert::assertEquals($value, $this->value->{$name}); + } + + return $this; + } + + /** + * Asserts that the value contains the provided properties $names. + * + * @return CoreExpectation + * + * @param iterable $names + */ + public function toHaveProperties(iterable $names): CoreExpectation + { + foreach ($names as $name) { + $this->toHaveProperty($name); + } + + return $this; + } + + /** + * Asserts that two variables have the same value. + * + * @return CoreExpectation + */ + public function toEqual(mixed $expected): CoreExpectation + { + Assert::assertEquals($expected, $this->value); + + return $this; + } + + /** + * Asserts that two variables have the same value. + * The contents of $expected and the $this->value are + * canonicalized before they are compared. For instance, when the two + * variables $expected and $this->value are arrays, then these arrays + * are sorted before they are compared. When $expected and $this->value + * are objects, each object is converted to an array containing all + * private, protected and public attributes. + * + * @return CoreExpectation + */ + public function toEqualCanonicalizing(mixed $expected): CoreExpectation + { + Assert::assertEqualsCanonicalizing($expected, $this->value); + + return $this; + } + + /** + * Asserts that the absolute difference between the value and $expected + * is lower than $delta. + * + * @return CoreExpectation + */ + public function toEqualWithDelta(mixed $expected, float $delta): CoreExpectation + { + Assert::assertEqualsWithDelta($expected, $this->value, $delta); + + return $this; + } + + /** + * Asserts that the value is one of the given values. + * + * @param iterable $values + * + * @return CoreExpectation + */ + public function toBeIn(iterable $values): CoreExpectation + { + Assert::assertContains($this->value, $values); + + return $this; + } + + /** + * Asserts that the value is infinite. + * + * @return CoreExpectation + */ + public function toBeInfinite(): CoreExpectation + { + Assert::assertInfinite($this->value); + + return $this; + } + + /** + * Asserts that the value is an instance of $class. + * + * @param class-string $class + * + * @return CoreExpectation + */ + public function toBeInstanceOf(string $class): CoreExpectation + { + Assert::assertInstanceOf($class, $this->value); + + return $this; + } + + /** + * Asserts that the value is an array. + * + * @return CoreExpectation + */ + public function toBeArray(): CoreExpectation + { + Assert::assertIsArray($this->value); + + return $this; + } + + /** + * Asserts that the value is of type bool. + * + * @return CoreExpectation + */ + public function toBeBool(): CoreExpectation + { + Assert::assertIsBool($this->value); + + return $this; + } + + /** + * Asserts that the value is of type callable. + * + * @return CoreExpectation + */ + public function toBeCallable(): CoreExpectation + { + Assert::assertIsCallable($this->value); + + return $this; + } + + /** + * Asserts that the value is of type float. + * + * @return CoreExpectation + */ + public function toBeFloat(): CoreExpectation + { + Assert::assertIsFloat($this->value); + + return $this; + } + + /** + * Asserts that the value is of type int. + * + * @return CoreExpectation + */ + public function toBeInt(): CoreExpectation + { + Assert::assertIsInt($this->value); + + return $this; + } + + /** + * Asserts that the value is of type iterable. + * + * @return CoreExpectation + */ + public function toBeIterable(): CoreExpectation + { + Assert::assertIsIterable($this->value); + + return $this; + } + + /** + * Asserts that the value is of type numeric. + * + * @return CoreExpectation + */ + public function toBeNumeric(): CoreExpectation + { + Assert::assertIsNumeric($this->value); + + return $this; + } + + /** + * Asserts that the value is of type object. + * + * @return CoreExpectation + */ + public function toBeObject(): CoreExpectation + { + Assert::assertIsObject($this->value); + + return $this; + } + + /** + * Asserts that the value is of type resource. + * + * @return CoreExpectation + */ + public function toBeResource(): CoreExpectation + { + Assert::assertIsResource($this->value); + + return $this; + } + + /** + * Asserts that the value is of type scalar. + * + * @return CoreExpectation + */ + public function toBeScalar(): CoreExpectation + { + Assert::assertIsScalar($this->value); + + return $this; + } + + /** + * Asserts that the value is of type string. + * + * @return CoreExpectation + */ + public function toBeString(): CoreExpectation + { + Assert::assertIsString($this->value); + + return $this; + } + + /** + * Asserts that the value is a JSON string. + * + * @return CoreExpectation + */ + public function toBeJson(): CoreExpectation + { + Assert::assertIsString($this->value); + + //@phpstan-ignore-next-line + Assert::assertJson($this->value); + + return $this; + } + + /** + * Asserts that the value is NAN. + * + * @return CoreExpectation + */ + public function toBeNan(): CoreExpectation + { + Assert::assertNan($this->value); + + return $this; + } + + /** + * Asserts that the value is null. + * + * @return CoreExpectation + */ + public function toBeNull(): CoreExpectation + { + Assert::assertNull($this->value); + + return $this; + } + + /** + * Asserts that the value array has the provided $key. + * + * @return CoreExpectation + */ + public function toHaveKey(string|int $key, mixed $value = null): CoreExpectation + { + if (is_object($this->value) && method_exists($this->value, 'toArray')) { + $array = $this->value->toArray(); + } else { + $array = (array) $this->value; + } + + try { + Assert::assertTrue(Arr::has($array, $key)); + + /* @phpstan-ignore-next-line */ + } catch (ExpectationFailedException $exception) { + throw new ExpectationFailedException("Failed asserting that an array has the key '$key'", $exception->getComparisonFailure()); + } + + if (func_num_args() > 1) { + Assert::assertEquals($value, Arr::get($array, $key)); + } + + return $this; + } + + /** + * Asserts that the value array has the provided $keys. + * + * @param array $keys + * + * @return CoreExpectation + */ + public function toHaveKeys(array $keys): CoreExpectation + { + foreach ($keys as $key) { + $this->toHaveKey($key); + } + + return $this; + } + + /** + * Asserts that the value is a directory. + * + * @return CoreExpectation + */ + public function toBeDirectory(): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + + Assert::assertDirectoryExists($this->value); + + return $this; + } + + /** + * Asserts that the value is a directory and is readable. + * + * @return CoreExpectation + */ + public function toBeReadableDirectory(): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + + Assert::assertDirectoryIsReadable($this->value); + + return $this; + } + + /** + * Asserts that the value is a directory and is writable. + * + * @return CoreExpectation + */ + public function toBeWritableDirectory(): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + + Assert::assertDirectoryIsWritable($this->value); + + return $this; + } + + /** + * Asserts that the value is a file. + * + * @return CoreExpectation + */ + public function toBeFile(): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + + Assert::assertFileExists($this->value); + + return $this; + } + + /** + * Asserts that the value is a file and is readable. + * + * @return CoreExpectation + */ + public function toBeReadableFile(): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + + Assert::assertFileIsReadable($this->value); + + return $this; + } + + /** + * Asserts that the value is a file and is writable. + * + * @return CoreExpectation + */ + public function toBeWritableFile(): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + Assert::assertFileIsWritable($this->value); + + return $this; + } + + /** + * Asserts that the value array matches the given array subset. + * + * @param iterable $array + * + * @return CoreExpectation + */ + public function toMatchArray(iterable|object $array): CoreExpectation + { + if (is_object($this->value) && method_exists($this->value, 'toArray')) { + $valueAsArray = $this->value->toArray(); + } else { + $valueAsArray = (array) $this->value; + } + + foreach ($array as $key => $value) { + Assert::assertArrayHasKey($key, $valueAsArray); + + Assert::assertEquals( + $value, + $valueAsArray[$key], + sprintf( + 'Failed asserting that an array has a key %s with the value %s.', + $this->export($key), + $this->export($valueAsArray[$key]), + ), + ); + } + + return $this; + } + + /** + * Asserts that the value object matches a subset + * of the properties of an given object. + * + * @param iterable|object $object + * + * @return CoreExpectation + */ + public function toMatchObject(iterable|object $object): CoreExpectation + { + foreach ((array) $object as $property => $value) { + if (!is_object($this->value) && !is_string($this->value)) { + InvalidExpectationValue::expected('object|string'); + } + + Assert::assertTrue(property_exists($this->value, $property)); + + /* @phpstan-ignore-next-line */ + $propertyValue = $this->value->{$property}; + Assert::assertEquals( + $value, + $propertyValue, + sprintf( + 'Failed asserting that an object has a property %s with the value %s.', + $this->export($property), + $this->export($propertyValue), + ), + ); + } + + return $this; + } + + /** + * Asserts that the value matches a regular expression. + * + * @return CoreExpectation + */ + public function toMatch(string $expression): CoreExpectation + { + if (!is_string($this->value)) { + InvalidExpectationValue::expected('string'); + } + Assert::assertMatchesRegularExpression($expression, $this->value); + + return $this; + } + + /** + * Asserts that the value matches a constraint. + * + * @return CoreExpectation + */ + public function toMatchConstraint(Constraint $constraint): CoreExpectation + { + Assert::assertThat($this->value, $constraint); + + return $this; + } + + /** + * Asserts that executing value throws an exception. + * + * @param (Closure(Throwable): mixed)|string $exception + * + * @return CoreExpectation + */ + public function toThrow(callable|string $exception, string $exceptionMessage = null): CoreExpectation + { + $callback = NullClosure::create(); + + if ($exception instanceof Closure) { + $callback = $exception; + $parameters = (new ReflectionFunction($exception))->getParameters(); + + if (1 !== count($parameters)) { + throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.'); + } + + if (!($type = $parameters[0]->getType()) instanceof ReflectionNamedType) { + throw new InvalidArgumentException('The given closure\'s parameter must be type-hinted as the class string.'); + } + + $exception = $type->getName(); + } + + try { + ($this->value)(); + } catch (Throwable $e) { // @phpstan-ignore-line + if (!class_exists($exception)) { + Assert::assertStringContainsString($exception, $e->getMessage()); + + return $this; + } + + if ($exceptionMessage !== null) { + Assert::assertStringContainsString($exceptionMessage, $e->getMessage()); + } + + Assert::assertInstanceOf($exception, $e); + $callback($e); + + return $this; + } + + if (!class_exists($exception)) { + throw new ExpectationFailedException("Exception with message \"$exception\" not thrown."); + } + + throw new ExpectationFailedException("Exception \"$exception\" not thrown."); + } + + /** + * Exports the given value. + */ + private function export(mixed $value): string + { + if ($this->exporter === null) { + $this->exporter = new Exporter(); + } + + return $this->exporter->export($value); + } +} diff --git a/src/Exceptions/PipeException.php b/src/Exceptions/PipeException.php new file mode 100644 index 00000000..9d6c2a27 --- /dev/null +++ b/src/Exceptions/PipeException.php @@ -0,0 +1,15 @@ + */ final class Expectation { @@ -34,20 +30,17 @@ final class Expectation __call as __extendsCall; } - /** - * The exporter instance, if any. - * - * @readonly - */ - private ?Exporter $exporter = null; + /** @var CoreExpectation */ + private CoreExpectation $coreExpectation; /** * Creates a new expectation. * * @param TValue $value */ - public function __construct(public mixed $value) + public function __construct(mixed $value) { + $this->coreExpectation = new CoreExpectation($value); } /** @@ -263,851 +256,83 @@ final class Expectation return $this; } - /** - * Asserts that two variables have the same type and - * value. Used on objects, it asserts that two - * variables reference the same object. - * - * @return self - */ - public function toBe(mixed $expected): Expectation - { - Assert::assertSame($expected, $this->value); - - return $this; - } - - /** - * Asserts that the value is empty. - * - * @return self - */ - public function toBeEmpty(): Expectation - { - Assert::assertEmpty($this->value); - - return $this; - } - - /** - * Asserts that the value is true. - * - * @return self - */ - public function toBeTrue(): Expectation - { - Assert::assertTrue($this->value); - - return $this; - } - - /** - * Asserts that the value is truthy. - * - * @return self - */ - public function toBeTruthy(): Expectation - { - Assert::assertTrue((bool) $this->value); - - return $this; - } - - /** - * Asserts that the value is false. - * - * @return self - */ - public function toBeFalse(): Expectation - { - Assert::assertFalse($this->value); - - return $this; - } - - /** - * Asserts that the value is falsy. - * - * @return self - */ - public function toBeFalsy(): Expectation - { - Assert::assertFalse((bool) $this->value); - - return $this; - } - - /** - * Asserts that the value is greater than $expected. - * - * @return self - */ - public function toBeGreaterThan(int|float $expected): Expectation - { - Assert::assertGreaterThan($expected, $this->value); - - return $this; - } - - /** - * Asserts that the value is greater than or equal to $expected. - * - * @return self - */ - public function toBeGreaterThanOrEqual(int|float $expected): Expectation - { - Assert::assertGreaterThanOrEqual($expected, $this->value); - - return $this; - } - - /** - * Asserts that the value is less than or equal to $expected. - * - * @return self - */ - public function toBeLessThan(int|float $expected): Expectation - { - Assert::assertLessThan($expected, $this->value); - - return $this; - } - - /** - * Asserts that the value is less than $expected. - * - * @return self - */ - public function toBeLessThanOrEqual(int|float $expected): Expectation - { - Assert::assertLessThanOrEqual($expected, $this->value); - - return $this; - } - - /** - * Asserts that $needle is an element of the value. - * - * @return self - */ - public function toContain(mixed ...$needles): Expectation - { - foreach ($needles as $needle) { - if (is_string($this->value)) { - // @phpstan-ignore-next-line - Assert::assertStringContainsString((string) $needle, $this->value); - } else { - if (!is_iterable($this->value)) { - InvalidExpectationValue::expected('iterable'); - } - Assert::assertContains($needle, $this->value); - } - } - - return $this; - } - - /** - * Asserts that the value starts with $expected. - * - * @param non-empty-string $expected - * - * @return self - */ - public function toStartWith(string $expected): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - - Assert::assertStringStartsWith($expected, $this->value); - - return $this; - } - - /** - * Asserts that the value ends with $expected. - * - * @param non-empty-string $expected - * - * @return self - */ - public function toEndWith(string $expected): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - - Assert::assertStringEndsWith($expected, $this->value); - - return $this; - } - - /** - * Asserts that $number matches value's Length. - * - * @return self - */ - public function toHaveLength(int $number): Expectation - { - if (is_string($this->value)) { - Assert::assertEquals($number, mb_strlen($this->value)); - - return $this; - } - - if (is_iterable($this->value)) { - return $this->toHaveCount($number); - } - - if (is_object($this->value)) { - if (method_exists($this->value, 'toArray')) { - $array = $this->value->toArray(); - } else { - $array = (array) $this->value; - } - - Assert::assertCount($number, $array); - - return $this; - } - - throw new BadMethodCallException('Expectation value length is not countable.'); - } - - /** - * Asserts that $count matches the number of elements of the value. - * - * @return self - */ - public function toHaveCount(int $count): Expectation - { - if (!is_countable($this->value) && !is_iterable($this->value)) { - InvalidExpectationValue::expected('string'); - } - - Assert::assertCount($count, $this->value); - - return $this; - } - - /** - * Asserts that the value contains the property $name. - * - * @return self - */ - public function toHaveProperty(string $name, mixed $value = null): Expectation - { - $this->toBeObject(); - - //@phpstan-ignore-next-line - Assert::assertTrue(property_exists($this->value, $name)); - - if (func_num_args() > 1) { - /* @phpstan-ignore-next-line */ - Assert::assertEquals($value, $this->value->{$name}); - } - - return $this; - } - - /** - * Asserts that the value contains the provided properties $names. - * - * @param iterable $names - * - * @return self - */ - public function toHaveProperties(iterable $names): Expectation - { - foreach ($names as $name) { - $this->toHaveProperty($name); - } - - return $this; - } - - /** - * Asserts that two variables have the same value. - * - * @return self - */ - public function toEqual(mixed $expected): Expectation - { - Assert::assertEquals($expected, $this->value); - - return $this; - } - - /** - * Asserts that two variables have the same value. - * The contents of $expected and the $this->value are - * canonicalized before they are compared. For instance, when the two - * variables $expected and $this->value are arrays, then these arrays - * are sorted before they are compared. When $expected and $this->value - * are objects, each object is converted to an array containing all - * private, protected and public attributes. - * - * @return self - */ - public function toEqualCanonicalizing(mixed $expected): Expectation - { - Assert::assertEqualsCanonicalizing($expected, $this->value); - - return $this; - } - - /** - * Asserts that the absolute difference between the value and $expected - * is lower than $delta. - * - * @return self - */ - public function toEqualWithDelta(mixed $expected, float $delta): Expectation - { - Assert::assertEqualsWithDelta($expected, $this->value, $delta); - - return $this; - } - - /** - * Asserts that the value is one of the given values. - * - * @param iterable $values - * - * @return self - */ - public function toBeIn(iterable $values): Expectation - { - Assert::assertContains($this->value, $values); - - return $this; - } - - /** - * Asserts that the value is infinite. - * - * @return self - */ - public function toBeInfinite(): Expectation - { - Assert::assertInfinite($this->value); - - return $this; - } - - /** - * Asserts that the value is an instance of $class. - * - * @param class-string $class - * - * @return self - */ - public function toBeInstanceOf(string $class): Expectation - { - Assert::assertInstanceOf($class, $this->value); - - return $this; - } - - /** - * Asserts that the value is an array. - * - * @return self - */ - public function toBeArray(): Expectation - { - Assert::assertIsArray($this->value); - - return $this; - } - - /** - * Asserts that the value is of type bool. - * - * @return self - */ - public function toBeBool(): Expectation - { - Assert::assertIsBool($this->value); - - return $this; - } - - /** - * Asserts that the value is of type callable. - * - * @return self - */ - public function toBeCallable(): Expectation - { - Assert::assertIsCallable($this->value); - - return $this; - } - - /** - * Asserts that the value is of type float. - * - * @return self - */ - public function toBeFloat(): Expectation - { - Assert::assertIsFloat($this->value); - - return $this; - } - - /** - * Asserts that the value is of type int. - * - * @return self - */ - public function toBeInt(): Expectation - { - Assert::assertIsInt($this->value); - - return $this; - } - - /** - * Asserts that the value is of type iterable. - * - * @return self - */ - public function toBeIterable(): Expectation - { - Assert::assertIsIterable($this->value); - - return $this; - } - - /** - * Asserts that the value is of type numeric. - * - * @return self - */ - public function toBeNumeric(): Expectation - { - Assert::assertIsNumeric($this->value); - - return $this; - } - - /** - * Asserts that the value is of type object. - * - * @return self - */ - public function toBeObject(): Expectation - { - Assert::assertIsObject($this->value); - - return $this; - } - - /** - * Asserts that the value is of type resource. - * - * @return self - */ - public function toBeResource(): Expectation - { - Assert::assertIsResource($this->value); - - return $this; - } - - /** - * Asserts that the value is of type scalar. - * - * @return self - */ - public function toBeScalar(): Expectation - { - Assert::assertIsScalar($this->value); - - return $this; - } - - /** - * Asserts that the value is of type string. - * - * @return self - */ - public function toBeString(): Expectation - { - Assert::assertIsString($this->value); - - return $this; - } - - /** - * Asserts that the value is a JSON string. - * - * @return self - */ - public function toBeJson(): Expectation - { - Assert::assertIsString($this->value); - - //@phpstan-ignore-next-line - Assert::assertJson($this->value); - - return $this; - } - - /** - * Asserts that the value is NAN. - * - * @return self - */ - public function toBeNan(): Expectation - { - Assert::assertNan($this->value); - - return $this; - } - - /** - * Asserts that the value is null. - * - * @return self - */ - public function toBeNull(): Expectation - { - Assert::assertNull($this->value); - - return $this; - } - - /** - * Asserts that the value array has the provided $key. - * - * @return self - */ - public function toHaveKey(string|int $key, mixed $value = null): Expectation - { - if (is_object($this->value) && method_exists($this->value, 'toArray')) { - $array = $this->value->toArray(); - } else { - $array = (array) $this->value; - } - - try { - Assert::assertTrue(Arr::has($array, $key)); - - /* @phpstan-ignore-next-line */ - } catch (ExpectationFailedException $exception) { - throw new ExpectationFailedException("Failed asserting that an array has the key '$key'", $exception->getComparisonFailure()); - } - - if (func_num_args() > 1) { - Assert::assertEquals($value, Arr::get($array, $key)); - } - - return $this; - } - - /** - * Asserts that the value array has the provided $keys. - * - * @param array $keys - * - * @return self - */ - public function toHaveKeys(array $keys): Expectation - { - foreach ($keys as $key) { - $this->toHaveKey($key); - } - - return $this; - } - - /** - * Asserts that the value is a directory. - * - * @return self - */ - public function toBeDirectory(): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - - Assert::assertDirectoryExists($this->value); - - return $this; - } - - /** - * Asserts that the value is a directory and is readable. - * - * @return self - */ - public function toBeReadableDirectory(): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - - Assert::assertDirectoryIsReadable($this->value); - - return $this; - } - - /** - * Asserts that the value is a directory and is writable. - * - * @return self - */ - public function toBeWritableDirectory(): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - - Assert::assertDirectoryIsWritable($this->value); - - return $this; - } - - /** - * Asserts that the value is a file. - * - * @return self - */ - public function toBeFile(): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - - Assert::assertFileExists($this->value); - - return $this; - } - - /** - * Asserts that the value is a file and is readable. - * - * @return self - */ - public function toBeReadableFile(): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - - Assert::assertFileIsReadable($this->value); - - return $this; - } - - /** - * Asserts that the value is a file and is writable. - * - * @return self - */ - public function toBeWritableFile(): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - Assert::assertFileIsWritable($this->value); - - return $this; - } - - /** - * Asserts that the value array matches the given array subset. - * - * @param iterable $array - * - * @return self - */ - public function toMatchArray(iterable|object $array): Expectation - { - if (is_object($this->value) && method_exists($this->value, 'toArray')) { - $valueAsArray = $this->value->toArray(); - } else { - $valueAsArray = (array) $this->value; - } - - foreach ($array as $key => $value) { - Assert::assertArrayHasKey($key, $valueAsArray); - - Assert::assertEquals( - $value, - $valueAsArray[$key], - sprintf( - 'Failed asserting that an array has a key %s with the value %s.', - $this->export($key), - $this->export($valueAsArray[$key]), - ), - ); - } - - return $this; - } - - /** - * Asserts that the value object matches a subset - * of the properties of an given object. - * - * @param iterable|object $object - * - * @return self - */ - public function toMatchObject(iterable|object $object): Expectation - { - foreach ((array) $object as $property => $value) { - if (!is_object($this->value) && !is_string($this->value)) { - InvalidExpectationValue::expected('object|string'); - } - - Assert::assertTrue(property_exists($this->value, $property)); - - /* @phpstan-ignore-next-line */ - $propertyValue = $this->value->{$property}; - Assert::assertEquals( - $value, - $propertyValue, - sprintf( - 'Failed asserting that an object has a property %s with the value %s.', - $this->export($property), - $this->export($propertyValue), - ), - ); - } - - return $this; - } - - /** - * Asserts that the value matches a regular expression. - * - * @return self - */ - public function toMatch(string $expression): Expectation - { - if (!is_string($this->value)) { - InvalidExpectationValue::expected('string'); - } - Assert::assertMatchesRegularExpression($expression, $this->value); - - return $this; - } - - /** - * Asserts that the value matches a constraint. - * - * @return self - */ - public function toMatchConstraint(Constraint $constraint): Expectation - { - Assert::assertThat($this->value, $constraint); - - return $this; - } - - /** - * Asserts that executing value throws an exception. - * - * @param (Closure(Throwable): mixed)|string $exception - * - * @return self - */ - public function toThrow(callable|string $exception, string $exceptionMessage = null): Expectation - { - $callback = NullClosure::create(); - - if ($exception instanceof Closure) { - $callback = $exception; - $parameters = (new ReflectionFunction($exception))->getParameters(); - - if (1 !== count($parameters)) { - throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.'); - } - - if (!($type = $parameters[0]->getType()) instanceof ReflectionNamedType) { - throw new InvalidArgumentException('The given closure\'s parameter must be type-hinted as the class string.'); - } - - $exception = $type->getName(); - } - - try { - ($this->value)(); - } catch (Throwable $e) { // @phpstan-ignore-line - if (!class_exists($exception)) { - Assert::assertStringContainsString($exception, $e->getMessage()); - - return $this; - } - - if ($exceptionMessage !== null) { - Assert::assertStringContainsString($exceptionMessage, $e->getMessage()); - } - - Assert::assertInstanceOf($exception, $e); - $callback($e); - - return $this; - } - - if (!class_exists($exception)) { - throw new ExpectationFailedException("Exception with message \"$exception\" not thrown."); - } - - throw new ExpectationFailedException("Exception \"$exception\" not thrown."); - } - - /** - * Exports the given value. - */ - private function export(mixed $value): string - { - if ($this->exporter === null) { - $this->exporter = new Exporter(); - } - - return $this->exporter->export($value); - } - /** * Dynamically handle calls to the class or * creates a new higher order expectation. * * @param array $parameters * - * @return HigherOrderExpectation|self|mixed + * @return Expectation|HigherOrderExpectation, TValue> */ - public function __call(string $method, array $parameters) + public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation { - if (!Expectation::hasExtend($method)) { + if (!$this->hasExpectation($method)) { /* @phpstan-ignore-next-line */ return new HigherOrderExpectation($this, $this->value->$method(...$parameters)); } - return $this->__extendsCall($method, $parameters); + ExpectationPipeline::for($this->getExpectationClosure($method)) + ->send(...$parameters) + ->through($this->pipes($method, $this, Expectation::class)) + ->run(); + + return $this; + } + + private function getExpectationClosure(string $name): Closure + { + if (method_exists($this->coreExpectation, $name)) { + //@phpstan-ignore-next-line + return Closure::fromCallable([$this->coreExpectation, $name]); + } + + if (self::hasExtend($name)) { + $extend = self::$extends[$name]->bindTo($this, Expectation::class); + + if ($extend != false) { + return $extend; + } + } + + throw PipeException::expectationNotFound($name); + } + + private function hasExpectation(string $name): bool + { + if (method_exists($this->coreExpectation, $name)) { + return true; + } + + if (self::hasExtend($name)) { + return true; + } + + return false; } /** * Dynamically calls methods on the class without any arguments * or creates a new higher order expectation. * - * @return self|OppositeExpectation|Each|HigherOrderExpectation + * @return Expectation|OppositeExpectation|Each|HigherOrderExpectation, TValue|null>|TValue */ - public function __get(string $name): Expectation|OppositeExpectation|Each|HigherOrderExpectation + public function __get(string $name) { - if (!method_exists($this, $name) && !Expectation::hasExtend($name)) { - //@phpstan-ignore-next-line + if ($name === 'value') { + return $this->coreExpectation->value; + } + + if (!method_exists($this, $name) && !method_exists($this->coreExpectation, $name) && !Expectation::hasExtend($name)) { + /* @phpstan-ignore-next-line */ return new HigherOrderExpectation($this, $this->retrieve($name, $this->value)); } /* @phpstan-ignore-next-line */ return $this->{$name}(); } + + public static function hasMethod(string $name): bool + { + return method_exists(CoreExpectation::class, $name); + } } diff --git a/src/HigherOrderExpectation.php b/src/HigherOrderExpectation.php index a760e845..8c4dcc4e 100644 --- a/src/HigherOrderExpectation.php +++ b/src/HigherOrderExpectation.php @@ -121,7 +121,7 @@ final class HigherOrderExpectation */ private function expectationHasMethod(string $name): bool { - return method_exists($this->original, $name) || $this->original::hasExtend($name); + return method_exists($this->original, $name) || $this->original::hasMethod($name) || $this->original::hasExtend($name); } /** diff --git a/src/Support/ExpectationPipeline.php b/src/Support/ExpectationPipeline.php new file mode 100644 index 00000000..0ddc9260 --- /dev/null +++ b/src/Support/ExpectationPipeline.php @@ -0,0 +1,70 @@ + */ + private array $pipes = []; + + /** @var array */ + private array $passable; + + private Closure $expectationClosure; + + public function __construct(Closure $expectationClosure) + { + $this->expectationClosure = $expectationClosure; + } + + public static function for(Closure $expectationClosure): self + { + return new self($expectationClosure); + } + + public function send(mixed ...$passable): self + { + $this->passable = $passable; + + return $this; + } + + /** + * @param array $pipes + */ + public function through(array $pipes): self + { + $this->pipes = $pipes; + + return $this; + } + + public function run(): void + { + $pipeline = array_reduce( + array_reverse($this->pipes), + $this->carry(), + function (): void { + ($this->expectationClosure)(...$this->passable); + } + ); + + $pipeline(); + } + + public function carry(): Closure + { + return function ($stack, $pipe): Closure { + return function () use ($stack, $pipe) { + return $pipe($stack, ...$this->passable); + }; + }; + } +} diff --git a/src/Support/Extendable.php b/src/Support/Extendable.php index 094d7007..fba8e17b 100644 --- a/src/Support/Extendable.php +++ b/src/Support/Extendable.php @@ -24,4 +24,17 @@ final class Extendable { $this->extendableClass::extend($name, $extend); } + + public function pipe(string $name, Closure $pipe): void + { + $this->extendableClass::pipe($name, $pipe); + } + + /** + * @param string|Closure $filter + */ + public function intercept(string $name, $filter, Closure $handler): void + { + $this->extendableClass::intercept($name, $filter, $handler); + } } diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 7bac8198..fe437645 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -186,6 +186,17 @@ PASS Tests\Features\Expect\not ✓ not property calls + PASS Tests\Features\Expect\pipe + ✓ pipe is applied and can stop pipeline + ✓ interceptor works with negated expectation + ✓ pipe works with negated expectation + ✓ pipe is run and can let the pipeline keep going + ✓ intercept is applied + ✓ intercept stops the pipeline + ✓ interception is called only when filter is met + ✓ intercept can be filtered with a closure + ✓ intercept can add new parameters to the expectation + PASS Tests\Features\Expect\ray ✓ ray calls do not fail when ray is not installed diff --git a/tests/Features/Expect/pipes.php b/tests/Features/Expect/pipes.php new file mode 100644 index 00000000..5e311e07 --- /dev/null +++ b/tests/Features/Expect/pipes.php @@ -0,0 +1,257 @@ +reset(); + } + + public function reset(): void + { + $this->appliedCount = $this->runCount = [ + 'char' => 0, + 'number' => 0, + 'wildcard' => 0, + 'symbol' => 0, + ]; + } +} + +$state = new State(); + +/* + * Overrides toBe to assert two Characters are the same + */ +expect()->pipe('toBe', function ($next, $expected) use ($state) { + $state->runCount['char']++; + + if ($this->value instanceof Char) { + $state->appliedCount['char']++; + + assertInstanceOf(Char::class, $expected); + assertEquals($this->value->value, $expected->value); + + //returning nothing stops pipeline execution + return; + } + + //calling $next(); let the pipeline to keep running + $next(); +}); + +/* + * Overrides toBe to assert two Number objects are the same + */ +expect()->intercept('toBe', Number::class, function ($expected) use ($state) { + $state->runCount['number']++; + $state->appliedCount['number']++; + + assertInstanceOf(Number::class, $expected); + assertEquals($this->value->value, $expected->value); +}); + +/* + * Overrides toBe to assert all integers are allowed if value is a wildcard (*) + */ +expect()->intercept('toBe', fn ($value, $expected) => $value === '*' && is_numeric($expected), function ($expected) use ($state) { + $state->runCount['wildcard']++; + $state->appliedCount['wildcard']++; +}); + +/* + * Overrides toBe to assert to Symbols are the same + */ +expect()->pipe('toBe', function ($next, $expected) use ($state) { + $state->runCount['symbol']++; + + if ($this->value instanceof Symbol) { + $state->appliedCount['symbol']++; + assertInstanceOf(Symbol::class, $expected); + assertEquals($this->value->value, $expected->value); + + return; + } + + $next(); +}); + +/* + * Overrides toBe to allow ignoring case when checking strings + */ +expect()->intercept('toBe', fn ($value) => is_string($value), function ($expected, $ignoreCase = false) { + if ($ignoreCase) { + assertEqualsIgnoringCase($expected, $this->value); + } else { + assertSame($expected, $this->value); + } +}); + +test('pipe is applied and can stop pipeline', function () use ($state) { + $char = new Char('A'); + + $state->reset(); + + expect($char)->toBe(new Char('A')) + ->and($state) + ->runCount->toMatchArray([ + 'char' => 1, + 'number' => 0, + 'wildcard' => 0, + 'symbol' => 0, + ]) + ->appliedCount->toMatchArray([ + 'char' => 1, + 'number' => 0, + 'wildcard' => 0, + 'symbol' => 0, + ]); +}); + +test('pipe is run and can let the pipeline keep going', function () use ($state) { + $state->reset(); + + expect(3)->toBe(3) + ->and($state) + ->runCount->toMatchArray([ + 'char' => 1, + 'number' => 0, + 'wildcard' => 0, + 'symbol' => 1, + ]) + ->appliedCount->toMatchArray([ + 'char' => 0, + 'number' => 0, + 'wildcard' => 0, + 'symbol' => 0, + ]); +}); + +test('pipe works with negated expectation', function () use ($state) { + $char = new Char('A'); + + $state->reset(); + + expect($char)->not->toBe(new Char('B')) + ->and($state) + ->runCount->toMatchArray([ + 'char' => 1, + 'number' => 0, + 'wildcard' => 0, + 'symbol' => 0, + ]) + ->appliedCount->toMatchArray([ + 'char' => 1, + 'number' => 0, + 'wildcard' => 0, + 'symbol' => 0, + ]); +}); + +test('interceptor is applied', function () use ($state) { + $number = new Number(1); + + $state->reset(); + + expect($number)->toBe(new Number(1)) + ->and($state) + ->runCount->toHaveKey('number', 1) + ->appliedCount->toHaveKey('number', 1); +}); + +test('interceptor stops the pipeline', function () use ($state) { + $number = new Number(1); + + $state->reset(); + + expect($number)->toBe(new Number(1)) + ->and($state) + ->runCount->toMatchArray([ + 'char' => 1, + 'number' => 1, + 'wildcard' => 0, + 'symbol' => 0, + ]) + ->appliedCount->toMatchArray([ + 'char' => 0, + 'number' => 1, + 'wildcard' => 0, + 'symbol' => 0, + ]); +}); + +test('interceptor is called only when filter is met', function () use ($state) { + $state->reset(); + + expect(1)->toBe(1) + ->and($state) + ->runCount->toHaveKey('number', 0) + ->appliedCount->toHaveKey('number', 0); +}); + +test('interceptor can be filtered with a closure', function () use ($state) { + $state->reset(); + + expect('*')->toBe(1) + ->and($state) + ->runCount->toHaveKey('wildcard', 1) + ->appliedCount->toHaveKey('wildcard', 1); +}); + +test('interceptor can be filter the expected parameter as well', function () use ($state) { + $state->reset(); + + expect('*')->toBe('*') + ->and($state) + ->runCount->toHaveKey('wildcard', 0) + ->appliedCount->toHaveKey('wildcard', 0); +}); + +test('interceptor works with negated expectation', function () { + $char = new Number(1); + + expect($char)->not->toBe(new Char('B')); +}); + +test('intercept can add new parameters to the expectation', function () { + $ignoreCase = true; + + expect('Foo')->toBe('foo', $ignoreCase); +});