diff --git a/composer.json b/composer.json index 893e8e41..6348d258 100644 --- a/composer.json +++ b/composer.json @@ -79,6 +79,7 @@ }, "pest": { "plugins": [ + "Pest\\Plugins\\Memory", "Pest\\Plugins\\Coverage", "Pest\\Plugins\\Init", "Pest\\Plugins\\Version", diff --git a/overrides/Runner/TestSuiteLoader.php b/overrides/Runner/TestSuiteLoader.php index 92fb003b..e690e2b5 100644 --- a/overrides/Runner/TestSuiteLoader.php +++ b/overrides/Runner/TestSuiteLoader.php @@ -43,7 +43,7 @@ use function array_values; use function basename; use function class_exists; use function get_declared_classes; -use Pest\IgnorableTestCase; +use Pest\TestCases\IgnorableTestCase; use Pest\TestSuite; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/src/Concerns/Extendable.php b/src/Concerns/Extendable.php index 5cf3e79d..a2f7e40b 100644 --- a/src/Concerns/Extendable.php +++ b/src/Concerns/Extendable.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Pest\Concerns; -use BadMethodCallException; use Closure; /** @@ -22,7 +21,7 @@ trait Extendable /** * Register a new extend. */ - public static function extend(string $name, Closure $extend): void + public function extend(string $name, Closure $extend): void { static::$extends[$name] = $extend; } @@ -34,21 +33,4 @@ trait Extendable { return array_key_exists($name, static::$extends); } - - /** - * Dynamically handle calls to the class. - * - * @param array $parameters - */ - public function __call(string $method, array $parameters): mixed - { - if (!static::hasExtend($method)) { - throw new BadMethodCallException("$method is not a callable method name."); - } - - /** @var Closure $extend */ - $extend = static::$extends[$method]->bindTo($this, static::class); - - return $extend(...$parameters); - } } diff --git a/src/Concerns/Pipeable.php b/src/Concerns/Pipeable.php new file mode 100644 index 00000000..79f32055 --- /dev/null +++ b/src/Concerns/Pipeable.php @@ -0,0 +1,65 @@ +> + */ + private static array $pipes = []; + + /** + * Register a pipe to be applied before an expectation is checked. + */ + public 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 function intercept(string $name, string|Closure $filter, Closure $handler): void + { + if (is_string($filter)) { + $filter = function ($value) use ($filter): bool { + return $value instanceof $filter; + }; + } + + $this->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(); + }); + } + + /** + * Get th list of pipes by the given name. + * + * @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] ?? []); + } +} diff --git a/src/Concerns/RetrievesValues.php b/src/Concerns/Retrievable.php similarity index 96% rename from src/Concerns/RetrievesValues.php rename to src/Concerns/Retrievable.php index c7c6cdd9..81dd75a3 100644 --- a/src/Concerns/RetrievesValues.php +++ b/src/Concerns/Retrievable.php @@ -7,7 +7,7 @@ namespace Pest\Concerns; /** * @internal */ -trait RetrievesValues +trait Retrievable { /** * @template TRetrievableValue diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 76e8bc88..e45f1956 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -7,6 +7,7 @@ namespace Pest\Concerns; use Closure; use Pest\Support\ChainableClosure; use Pest\Support\ExceptionTrace; +use Pest\Support\Reflection; use Pest\TestSuite; use Throwable; @@ -210,7 +211,28 @@ trait Testable */ private function __resolveTestArguments(array $arguments): array { - return array_map(fn ($data) => $data instanceof Closure ? $this->__callClosure($data, []) : $data, $arguments); + if (count($arguments) !== 1) { + return $arguments; + } + + if (!$arguments[0] instanceof Closure) { + return $arguments; + } + + $underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure'); + $testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest)); + + if (in_array($testParameterTypes[0], ['Closure', 'callable'])) { + return $arguments; + } + + $boundDatasetResult = $this->__callClosure($arguments[0], []); + + if (count($testParameterTypes) === 1 || !is_array($boundDatasetResult)) { + return [$boundDatasetResult]; + } + + return array_values($boundDatasetResult); } /** diff --git a/src/Exceptions/ExpectationNotFound.php b/src/Exceptions/ExpectationNotFound.php new file mode 100644 index 00000000..af47c03f --- /dev/null +++ b/src/Exceptions/ExpectationNotFound.php @@ -0,0 +1,21 @@ + */ final class Expectation { - use RetrievesValues, Extendable { - __call as __extendsCall; - } - - /** - * The exporter instance, if any. - * - * @readonly - */ - private ?Exporter $exporter = null; + use Extendable; + use Pipeable; + use Retrievable; /** * Creates a new expectation. * * @param TValue $value */ - public function __construct(public mixed $value) - { + public function __construct( + public mixed $value + ) { + // .. } /** @@ -121,9 +116,9 @@ final class Expectation /** * Creates an expectation on each item of the iterable "value". * - * @return Each + * @return EachExpectation */ - public function each(callable $callback = null): Each + public function each(callable $callback = null): EachExpectation { if (!is_iterable($this->value)) { throw new BadMethodCallException('Expectation value is not iterable.'); @@ -135,7 +130,7 @@ final class Expectation } } - return new Each($this); + return new EachExpectation($this); } /** @@ -264,850 +259,73 @@ final class Expectation } /** - * 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. + * Dynamically calls methods on 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 (!self::hasMethod($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; } /** - * Dynamically calls methods on the class without any arguments - * or creates a new higher order expectation. + * Creates a new expectation closure from the given name. * - * @return self|OppositeExpectation|Each|HigherOrderExpectation + * @throws ExpectationNotFound */ - public function __get(string $name): Expectation|OppositeExpectation|Each|HigherOrderExpectation + private function getExpectationClosure(string $name): Closure { - if (!method_exists($this, $name) && !Expectation::hasExtend($name)) { + if (method_exists(Mixins\Expectation::class, $name)) { //@phpstan-ignore-next-line + return Closure::fromCallable([new Mixins\Expectation($this->value), $name]); + } + + if (self::hasExtend($name)) { + $extend = self::$extends[$name]->bindTo($this, Expectation::class); + + if ($extend != false) { + return $extend; + } + } + + throw ExpectationNotFound::fromName($name); + } + + /** + * Dynamically calls methods on the class without any arguments or creates a new higher order expectation. + * + * @return Expectation|OppositeExpectation|EachExpectation|HigherOrderExpectation, TValue|null>|TValue + */ + public function __get(string $name) + { + if (!self::hasMethod($name)) { + /* @phpstan-ignore-next-line */ return new HigherOrderExpectation($this, $this->retrieve($name, $this->value)); } /* @phpstan-ignore-next-line */ return $this->{$name}(); } + + /** + * Checks if the given expectation method exists. + */ + public static function hasMethod(string $name): bool + { + return method_exists(self::class, $name) + || method_exists(Mixins\Expectation::class, $name) + || self::hasExtend($name); + } } diff --git a/src/Each.php b/src/Expectations/EachExpectation.php similarity index 88% rename from src/Each.php rename to src/Expectations/EachExpectation.php index 19105945..df0cb1d0 100644 --- a/src/Each.php +++ b/src/Expectations/EachExpectation.php @@ -2,7 +2,10 @@ declare(strict_types=1); -namespace Pest; +namespace Pest\Expectations; + +use function expect; +use Pest\Expectation; /** * @internal @@ -11,7 +14,7 @@ namespace Pest; * * @mixin Expectation */ -final class Each +final class EachExpectation { private bool $opposite = false; @@ -43,7 +46,7 @@ final class Each * * @return self */ - public function not(): Each + public function not(): EachExpectation { $this->opposite = true; @@ -57,7 +60,7 @@ final class Each * * @return self */ - public function __call(string $name, array $arguments): Each + public function __call(string $name, array $arguments): EachExpectation { foreach ($this->original->value as $item) { /* @phpstan-ignore-next-line */ @@ -74,7 +77,7 @@ final class Each * * @return self */ - public function __get(string $name): Each + public function __get(string $name): EachExpectation { /* @phpstan-ignore-next-line */ return $this->$name(); diff --git a/src/HigherOrderExpectation.php b/src/Expectations/HigherOrderExpectation.php similarity index 92% rename from src/HigherOrderExpectation.php rename to src/Expectations/HigherOrderExpectation.php index a760e845..83c43643 100644 --- a/src/HigherOrderExpectation.php +++ b/src/Expectations/HigherOrderExpectation.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace Pest; +namespace Pest\Expectations; -use Pest\Concerns\RetrievesValues; +use Pest\Concerns\Retrievable; +use Pest\Expectation; /** * @internal @@ -16,12 +17,12 @@ use Pest\Concerns\RetrievesValues; */ final class HigherOrderExpectation { - use RetrievesValues; + use Retrievable; /** - * @var Expectation|Each + * @var Expectation|EachExpectation */ - private Expectation|Each $expectation; + private Expectation|EachExpectation $expectation; private bool $opposite = false; @@ -121,7 +122,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/OppositeExpectation.php b/src/Expectations/OppositeExpectation.php similarity index 98% rename from src/OppositeExpectation.php rename to src/Expectations/OppositeExpectation.php index e2062e75..1ae9ccbe 100644 --- a/src/OppositeExpectation.php +++ b/src/Expectations/OppositeExpectation.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Pest; +namespace Pest\Expectations; +use Pest\Expectation; use PHPUnit\Framework\ExpectationFailedException; use SebastianBergmann\Exporter\Exporter; diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php index 83f1fd9a..6265f528 100644 --- a/src/Factories/TestCaseFactory.php +++ b/src/Factories/TestCaseFactory.php @@ -150,7 +150,7 @@ final class TestCaseFactory eval(" namespace $namespace; - use Pest\Datasets as __PestDatasets; + use Pest\Repositories\DatasetsRepository as __PestDatasets; use Pest\TestSuite as __PestTestSuite; final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { diff --git a/src/Factories/TestCaseMethodFactory.php b/src/Factories/TestCaseMethodFactory.php index 50214370..987a2d27 100644 --- a/src/Factories/TestCaseMethodFactory.php +++ b/src/Factories/TestCaseMethodFactory.php @@ -5,10 +5,10 @@ declare(strict_types=1); namespace Pest\Factories; use Closure; -use Pest\Datasets; use Pest\Exceptions\ShouldNotHappen; use Pest\Factories\Concerns\HigherOrderable; use Pest\Plugins\Retry; +use Pest\Repositories\DatasetsRepository; use Pest\Support\Str; use Pest\TestSuite; use PHPUnit\Framework\Assert; @@ -159,7 +159,7 @@ final class TestCaseMethodFactory */ private function buildDatasetForEvaluation(string $methodName, string $dataProviderName): string { - Datasets::with($this->filename, $methodName, $this->datasets); + DatasetsRepository::with($this->filename, $methodName, $this->datasets); return <<|Extendable + * @return Expectation */ - function expect($value = null): Expectation|Extendable + function expect(mixed $value = null): Expectation { - if (func_num_args() === 0) { - return new Extendable(Expectation::class); - } - return new Expectation($value); } } @@ -66,7 +61,7 @@ if (!function_exists('dataset')) { */ function dataset(string $name, Closure|iterable $dataset): void { - Datasets::set($name, $dataset); + DatasetsRepository::set($name, $dataset); } } diff --git a/src/Laravel/Commands/PestDatasetCommand.php b/src/Laravel/Commands/PestDatasetCommand.php index 33ae8db8..0a7847e7 100644 --- a/src/Laravel/Commands/PestDatasetCommand.php +++ b/src/Laravel/Commands/PestDatasetCommand.php @@ -42,7 +42,7 @@ final class PestDatasetCommand extends Command /** @var string $name */ $name = $this->argument('name'); - $relativePath = sprintf(testDirectory('Datasets/%s.php'), ucfirst($name)); + $relativePath = sprintf(testDirectory('DatasetsRepository/%s.php'), ucfirst($name)); /* @phpstan-ignore-next-line */ $target = base_path($relativePath); diff --git a/src/Mixins/Expectation.php b/src/Mixins/Expectation.php new file mode 100644 index 00000000..3fbd2110 --- /dev/null +++ b/src/Mixins/Expectation.php @@ -0,0 +1,860 @@ + + */ +final class Expectation +{ + /** + * 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 Expectation + */ + public function toBe(mixed $expected): Expectation + { + Assert::assertSame($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is empty. + * + * @return Expectation + */ + public function toBeEmpty(): Expectation + { + Assert::assertEmpty($this->value); + + return $this; + } + + /** + * Asserts that the value is true. + * + * @return Expectation + */ + public function toBeTrue(): Expectation + { + Assert::assertTrue($this->value); + + return $this; + } + + /** + * Asserts that the value is truthy. + * + * @return Expectation + */ + public function toBeTruthy(): Expectation + { + Assert::assertTrue((bool) $this->value); + + return $this; + } + + /** + * Asserts that the value is false. + * + * @return Expectation + */ + public function toBeFalse(): Expectation + { + Assert::assertFalse($this->value); + + return $this; + } + + /** + * Asserts that the value is falsy. + * + * @return Expectation + */ + public function toBeFalsy(): Expectation + { + Assert::assertFalse((bool) $this->value); + + return $this; + } + + /** + * Asserts that the value is greater than $expected. + * + * @return Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + public function toBeLessThan(int|float $expected): Expectation + { + Assert::assertLessThan($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is less than $expected. + * + * @return Expectation + */ + public function toBeLessThanOrEqual(int|float $expected): Expectation + { + Assert::assertLessThanOrEqual($expected, $this->value); + + return $this; + } + + /** + * Asserts that $needle is an element of the value. + * + * @return Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + public function toHaveProperties(iterable $names): Expectation + { + foreach ($names as $name) { + $this->toHaveProperty($name); + } + + return $this; + } + + /** + * Asserts that two variables have the same value. + * + * @return Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + public function toBeIn(iterable $values): Expectation + { + Assert::assertContains($this->value, $values); + + return $this; + } + + /** + * Asserts that the value is infinite. + * + * @return Expectation + */ + public function toBeInfinite(): Expectation + { + Assert::assertInfinite($this->value); + + return $this; + } + + /** + * Asserts that the value is an instance of $class. + * + * @param class-string $class + * + * @return Expectation + */ + public function toBeInstanceOf(string $class): Expectation + { + Assert::assertInstanceOf($class, $this->value); + + return $this; + } + + /** + * Asserts that the value is an array. + * + * @return Expectation + */ + public function toBeArray(): Expectation + { + Assert::assertIsArray($this->value); + + return $this; + } + + /** + * Asserts that the value is of type bool. + * + * @return Expectation + */ + public function toBeBool(): Expectation + { + Assert::assertIsBool($this->value); + + return $this; + } + + /** + * Asserts that the value is of type callable. + * + * @return Expectation + */ + public function toBeCallable(): Expectation + { + Assert::assertIsCallable($this->value); + + return $this; + } + + /** + * Asserts that the value is of type float. + * + * @return Expectation + */ + public function toBeFloat(): Expectation + { + Assert::assertIsFloat($this->value); + + return $this; + } + + /** + * Asserts that the value is of type int. + * + * @return Expectation + */ + public function toBeInt(): Expectation + { + Assert::assertIsInt($this->value); + + return $this; + } + + /** + * Asserts that the value is of type iterable. + * + * @return Expectation + */ + public function toBeIterable(): Expectation + { + Assert::assertIsIterable($this->value); + + return $this; + } + + /** + * Asserts that the value is of type numeric. + * + * @return Expectation + */ + public function toBeNumeric(): Expectation + { + Assert::assertIsNumeric($this->value); + + return $this; + } + + /** + * Asserts that the value is of type object. + * + * @return Expectation + */ + public function toBeObject(): Expectation + { + Assert::assertIsObject($this->value); + + return $this; + } + + /** + * Asserts that the value is of type resource. + * + * @return Expectation + */ + public function toBeResource(): Expectation + { + Assert::assertIsResource($this->value); + + return $this; + } + + /** + * Asserts that the value is of type scalar. + * + * @return Expectation + */ + public function toBeScalar(): Expectation + { + Assert::assertIsScalar($this->value); + + return $this; + } + + /** + * Asserts that the value is of type string. + * + * @return Expectation + */ + public function toBeString(): Expectation + { + Assert::assertIsString($this->value); + + return $this; + } + + /** + * Asserts that the value is a JSON string. + * + * @return Expectation + */ + 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 Expectation + */ + public function toBeNan(): Expectation + { + Assert::assertNan($this->value); + + return $this; + } + + /** + * Asserts that the value is null. + * + * @return Expectation + */ + public function toBeNull(): Expectation + { + Assert::assertNull($this->value); + + return $this; + } + + /** + * Asserts that the value array has the provided $key. + * + * @return Expectation + */ + 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 Expectation + */ + public function toHaveKeys(array $keys): Expectation + { + foreach ($keys as $key) { + $this->toHaveKey($key); + } + + return $this; + } + + /** + * Asserts that the value is a directory. + * + * @return Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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 Expectation + */ + 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); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 5f676be4..6e73d59c 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -7,8 +7,7 @@ namespace Pest; final class Plugin { /** - * The lazy callables to be executed - * once the test suite boots. + * The lazy callables to be executed once the test suite boots. * * @var array * diff --git a/src/Plugins/Memory.php b/src/Plugins/Memory.php new file mode 100644 index 00000000..ffaf3358 --- /dev/null +++ b/src/Plugins/Memory.php @@ -0,0 +1,50 @@ +output = $output; + } + + public function handleArguments(array $arguments): array + { + foreach ($arguments as $index => $argument) { + if ($argument === '--memory') { + unset($arguments[$index]); + + $this->enabled = true; + } + } + + return array_values($arguments); + } + + public function addOutput(int $result): int + { + if ($this->enabled) { + $this->output->writeln(sprintf( + ' Memory: %s MB', + round(memory_get_usage(true) / pow(1000, 2), 3) + )); + } + + return $result; + } +} diff --git a/src/Datasets.php b/src/Repositories/DatasetsRepository.php similarity index 98% rename from src/Datasets.php rename to src/Repositories/DatasetsRepository.php index 248b1d38..d8a57bfa 100644 --- a/src/Datasets.php +++ b/src/Repositories/DatasetsRepository.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest; +namespace Pest\Repositories; use Closure; use Pest\Exceptions\DatasetAlreadyExist; @@ -15,7 +15,7 @@ use Traversable; /** * @internal */ -final class Datasets +final class DatasetsRepository { /** * Holds the datasets. diff --git a/src/Support/ExpectationPipeline.php b/src/Support/ExpectationPipeline.php new file mode 100644 index 00000000..11481576 --- /dev/null +++ b/src/Support/ExpectationPipeline.php @@ -0,0 +1,98 @@ + + */ + private array $pipes = []; + + /** + * The list of passables. + * + * @var array + */ + private array $passables; + + /** + * The expectation closure. + */ + private Closure $closure; + + /** + * Creates a new instance of Expectation Pipeline. + */ + public function __construct(Closure $closure) + { + $this->closure = $closure; + } + + /** + * Creates a new instance of Expectation Pipeline with given closure. + */ + public static function for(Closure $closure): self + { + return new self($closure); + } + + /** + * Sets the list of passables. + */ + public function send(mixed ...$passables): self + { + $this->passables = array_values($passables); + + return $this; + } + + /** + * Sets the list of pipes. + * + * @param array $pipes + */ + public function through(array $pipes): self + { + $this->pipes = $pipes; + + return $this; + } + + /** + * Runs the pipeline. + */ + public function run(): void + { + $pipeline = array_reduce( + array_reverse($this->pipes), + $this->carry(), + function (): void { + ($this->closure)(...$this->passables); + } + ); + + $pipeline(); + } + + /** + * Get a Closure that will carry of the expectation. + */ + public function carry(): Closure + { + return function ($stack, $pipe): Closure { + return function () use ($stack, $pipe) { + return $pipe($stack, ...$this->passables); + }; + }; + } +} diff --git a/src/Support/Extendable.php b/src/Support/Extendable.php deleted file mode 100644 index 094d7007..00000000 --- a/src/Support/Extendable.php +++ /dev/null @@ -1,27 +0,0 @@ -extendableClass::extend($name, $extend); - } -} diff --git a/src/Support/Reflection.php b/src/Support/Reflection.php index b1d6216b..3a0a554a 100644 --- a/src/Support/Reflection.php +++ b/src/Support/Reflection.php @@ -205,4 +205,12 @@ final class Reflection return $arguments; } + + /** + * @return mixed + */ + public static function getFunctionVariable(Closure $function, string $key) + { + return (new ReflectionFunction($function))->getStaticVariables()[$key] ?? null; + } } diff --git a/src/IgnorableTestCase.php b/src/TestCases/IgnorableTestCase.php similarity index 85% rename from src/IgnorableTestCase.php rename to src/TestCases/IgnorableTestCase.php index ba4a4bf1..a2e9f5e9 100644 --- a/src/IgnorableTestCase.php +++ b/src/TestCases/IgnorableTestCase.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest; +namespace Pest\TestCases; use PHPUnit\Framework\TestCase; diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 0c6a57ab..fe437645 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -96,11 +96,20 @@ ✓ more than two datasets with (2) / (4) / (5) ✓ more than two datasets with (2) / (4) / (6) ✓ more than two datasets did the job right - ✓ it can resolve a dataset after the test case is available with (Closure Object (...)) + ✓ it can resolve a dataset after the test case is available with (Closure Object (...)) #1 + ✓ it can resolve a dataset after the test case is available with (Closure Object (...)) #2 ✓ it can resolve a dataset after the test case is available with shared yield sets with (Closure Object (...)) #1 ✓ it can resolve a dataset after the test case is available with shared yield sets with (Closure Object (...)) #2 ✓ it can resolve a dataset after the test case is available with shared array sets with (Closure Object (...)) #1 ✓ it can resolve a dataset after the test case is available with shared array sets with (Closure Object (...)) #2 + ✓ it resolves a potential bound dataset logically with ('foo', Closure Object (...)) + ✓ it resolves a potential bound dataset logically even when the closure comes first with (Closure Object (...), 'bar') + ✓ it will not resolve a closure if it is type hinted as a closure with (Closure Object (...)) #1 + ✓ it will not resolve a closure if it is type hinted as a closure with (Closure Object (...)) #2 + ✓ it will not resolve a closure if it is type hinted as a callable with (Closure Object (...)) #1 + ✓ it will not resolve a closure if it is type hinted as a callable with (Closure Object (...)) #2 + ✓ it can correctly resolve a bound dataset that returns an array with (Closure Object (...)) + ✓ it can correctly resolve a bound dataset that returns an array but wants to be spread with (Closure Object (...)) PASS Tests\Features\Exceptions ✓ it gives access the the underlying expectException @@ -177,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 @@ -720,5 +740,5 @@ ✓ it is a test ✓ it uses correct parent class - Tests: 4 incompleted, 9 skipped, 478 passed + Tests: 4 incompleted, 9 skipped, 487 passed \ No newline at end of file diff --git a/tests/Features/Datasets.php b/tests/Features/Datasets.php index 753c4eaf..4b7f9dc3 100644 --- a/tests/Features/Datasets.php +++ b/tests/Features/Datasets.php @@ -1,9 +1,9 @@ foo = 'bar'; @@ -13,28 +13,28 @@ it('throws exception if dataset does not exist', function () { $this->expectException(DatasetDoesNotExist::class); $this->expectExceptionMessage("A dataset with the name `first` does not exist. You can create it using `dataset('first', ['a', 'b']);`."); - Datasets::resolve('foo', ['first']); + DatasetsRepository::resolve('foo', ['first']); }); it('throws exception if dataset already exist', function () { - Datasets::set('second', [[]]); + DatasetsRepository::set('second', [[]]); $this->expectException(DatasetAlreadyExist::class); $this->expectExceptionMessage('A dataset with the name `second` already exist.'); - Datasets::set('second', [[]]); + DatasetsRepository::set('second', [[]]); }); it('sets closures', function () { - Datasets::set('foo', function () { + DatasetsRepository::set('foo', function () { yield [1]; }); - expect(Datasets::resolve('foo', ['foo']))->toBe(['foo with (1)' => [1]]); + expect(DatasetsRepository::resolve('foo', ['foo']))->toBe(['foo with (1)' => [1]]); }); it('sets arrays', function () { - Datasets::set('bar', [[2]]); + DatasetsRepository::set('bar', [[2]]); - expect(Datasets::resolve('bar', ['bar']))->toBe(['bar with (2)' => [2]]); + expect(DatasetsRepository::resolve('bar', ['bar']))->toBe(['bar with (2)' => [2]]); }); it('gets bound to test case object', function () { @@ -234,6 +234,7 @@ it('can resolve a dataset after the test case is available', function ($result) expect($result)->toBe('bar'); })->with([ function () { return $this->foo; }, + [function () { return $this->foo; }], ]); it('can resolve a dataset after the test case is available with shared yield sets', function ($result) { @@ -243,3 +244,43 @@ it('can resolve a dataset after the test case is available with shared yield set it('can resolve a dataset after the test case is available with shared array sets', function ($result) { expect($result)->toBeInt()->toBeLessThan(3); })->with('bound.array'); + +it('resolves a potential bound dataset logically', function ($foo, $bar) { + expect($foo)->toBe('foo'); + expect($bar())->toBe('bar'); +})->with([ + ['foo', function () { return 'bar'; }], // This should be passed as a closure because we've passed multiple arguments +]); + +it('resolves a potential bound dataset logically even when the closure comes first', function ($foo, $bar) { + expect($foo())->toBe('foo'); + expect($bar)->toBe('bar'); +})->with([ + [function () { return 'foo'; }, 'bar'], // This should be passed as a closure because we've passed multiple arguments +]); + +it('will not resolve a closure if it is type hinted as a closure', function (Closure $data) { + expect($data())->toBeString(); +})->with([ + function () { return 'foo'; }, + function () { return 'bar'; }, +]); + +it('will not resolve a closure if it is type hinted as a callable', function (callable $data) { + expect($data())->toBeString(); +})->with([ + function () { return 'foo'; }, + function () { return 'bar'; }, +]); + +it('can correctly resolve a bound dataset that returns an array', function (array $data) { + expect($data)->toBe(['foo', 'bar', 'baz']); +})->with([ + function () { return ['foo', 'bar', 'baz']; }, +]); + +it('can correctly resolve a bound dataset that returns an array but wants to be spread', function (string $foo, string $bar, string $baz) { + expect([$foo, $bar, $baz])->toBe(['foo', 'bar', 'baz']); +})->with([ + function () { return ['foo', 'bar', 'baz']; }, +]); 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); +}); diff --git a/tests/Unit/Datasets.php b/tests/Unit/Datasets.php index 08f82352..a93ac62e 100644 --- a/tests/Unit/Datasets.php +++ b/tests/Unit/Datasets.php @@ -1,9 +1,9 @@ [1], 'two' => [[2]], @@ -15,7 +15,7 @@ it('show only the names of named datasets in their description', function () { }); it('show the actual dataset of non-named datasets in their description', function () { - $descriptions = array_keys(Datasets::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve('test description', [ [ [1], [[2]], @@ -27,7 +27,7 @@ it('show the actual dataset of non-named datasets in their description', functio }); it('show only the names of multiple named datasets in their description', function () { - $descriptions = array_keys(Datasets::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve('test description', [ [ 'one' => [1], 'two' => [[2]], @@ -45,7 +45,7 @@ it('show only the names of multiple named datasets in their description', functi }); it('show the actual dataset of multiple non-named datasets in their description', function () { - $descriptions = array_keys(Datasets::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve('test description', [ [ [1], [[2]], @@ -63,7 +63,7 @@ it('show the actual dataset of multiple non-named datasets in their description' }); it('show the correct description for mixed named and not-named datasets', function () { - $descriptions = array_keys(Datasets::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve('test description', [ [ 'one' => [1], [[2]],