diff --git a/src/Concerns/Extendable.php b/src/Concerns/Extendable.php index 2f8dedc..8340f80 100644 --- a/src/Concerns/Extendable.php +++ b/src/Concerns/Extendable.php @@ -4,8 +4,8 @@ namespace Pest\Expectations\Concerns; -use BadMethodCallException; use Closure; +use Pest\Expectations\HigherOrderExpectation; /** * @internal @@ -40,12 +40,11 @@ public static function hasExtend(string $name): bool * * @return mixed * - * @throws BadMethodCallException */ public function __call(string $method, array $parameters) { if (!static::hasExtend($method)) { - throw new BadMethodCallException(sprintf('Method %s::%s does not exist.', static::class, $method)); + return new HigherOrderExpectation($this, $method, $parameters); } /** @var Closure $extend */ diff --git a/src/Expectation.php b/src/Expectation.php index 5442a61..062e977 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -680,12 +680,17 @@ private function export($value): string } /** - * Dynamically calls methods on the class without any arguments. + * Dynamically calls methods on the class without any arguments + * or creates a new higher order expectation. * - * @return Expectation + * @return Expectation|HigherOrderExpectation */ public function __get(string $name) { + if (!method_exists($this, $name) && !static::hasExtend($name)) { + return new HigherOrderExpectation($this, $name); + } + /* @phpstan-ignore-next-line */ return $this->{$name}(); } diff --git a/src/HigherOrderExpectation.php b/src/HigherOrderExpectation.php new file mode 100644 index 0000000..dd4ee93 --- /dev/null +++ b/src/HigherOrderExpectation.php @@ -0,0 +1,141 @@ +original = $original; + $this->name = $name; + $this->generateInitialExpectation($parameters); + } + + /** + * Generates the initial expectation for use within the higher order expectation. + */ + private function generateInitialExpectation(?array $parameters = null) + { + $this->expectation = $this->expect( + is_null($parameters) ? $this->getPropertyValue() : $this->getMethodValue($parameters) + ); + } + + /** + * Retrieves the property value from the original expectation. + */ + private function getPropertyValue() + { + if (is_array($this->original->value)) { + return $this->original->value[$this->name]; + } + + if (is_object($this->original->value)) { + return $this->original->value->{$this->name}; + } + } + + /** + * Retrieves the value of the method from the original expectation. + */ + private function getMethodValue(array $arguments) + { + return $this->original->value->{$this->name}(...$arguments); + } + + /** + * Creates the opposite expectation for the value. + */ + public function not(): HigherOrderExpectation + { + $this->opposite = !$this->opposite; + + return $this; + } + + /** + * Dynamically calls methods on the class with the given parameters on each item. + * + * @param array $parameters + */ + public function __call(string $name, array $parameters): HigherOrderExpectation + { + if (!$this->originalHasMethod($name)) { + return new static($this->original, $name, $parameters); + } + + return $this->performAssertion($name, ...$parameters); + } + + /** + * Accesses properties in the value or in the expectation. + */ + public function __get(string $name): HigherOrderExpectation + { + if ($name == 'not') { + return $this->not(); + } + + if (!$this->originalHasMethod($name)) { + return new static($this->original, $name); + } + + return $this->performAssertion($name); + } + + /** + * Determines if the original expectation has the given method name. + */ + private function originalHasMethod($name): bool + { + return method_exists($this->original, $name) || $this->original::hasExtend($name); + } + + /** + * Performs the given assertion with the current expectation. + */ + private function performAssertion($name, ...$arguments): HigherOrderExpectation + { + $this->expectation = $this->opposite + ? $this->expectation->not()->{$name}(...$arguments) + : $this->expectation->{$name}(...$arguments); + + $this->opposite = false; + + return $this; + } +} diff --git a/tests/Expect/HigherOrder/methods.php b/tests/Expect/HigherOrder/methods.php new file mode 100644 index 0000000..64490e9 --- /dev/null +++ b/tests/Expect/HigherOrder/methods.php @@ -0,0 +1,100 @@ +name()->toBeString()->toEqual('Has Methods'); +}); + +it('can access multiple methods', function () { + expect(new HasMethods) + ->name()->toBeString()->toEqual('Has Methods') + ->quantity()->toBeInt()->toEqual(20); +}); + +it('works with not', function () { + expect(new HasMethods) + ->name()->not->toEqual('world')->toEqual('Has Methods') + ->quantity()->toEqual(20)->not()->toEqual('bar')->not->toBeNull; +}); + +it('can accept arguments', function () { + expect(new HasMethods) + ->multiply(5, 4)->toBeInt->toEqual(20); +}); + +it('works with each', function () { + expect(new HasMethods) + ->attributes()->toBeArray->each->not()->toBeNull + ->attributes()->each(function ($attribute) { + $attribute->not->toBeNull(); + }); +}); + +it('works inside of each', function () { + expect(new HasMethods) + ->books()->each(function ($book) { + $book->title->not->toBeNull->cost->toBeGreaterThan(19); + }); +}); + +it('works with sequence', function () { + expect(new HasMethods) + ->books()->sequence( + function ($book) { $book->title->toEqual('Foo')->cost->toEqual(20); }, + function ($book) { $book->title->toEqual('Bar')->cost->toEqual(30); }, + ); +}); + +it('can compose complex expectations', function () { + expect(new HasMethods) + ->toBeObject() + ->name()->toEqual('Has Methods')->not()->toEqual('bar') + ->quantity()->not->toEqual('world')->toEqual(20)->toBeInt + ->multiply(3, 4)->not->toBeString->toEqual(12) + ->attributes()->toBeArray() + ->books()->toBeArray->each->not->toBeEmpty + ->books()->sequence( + function ($book) { $book->title->toEqual('Foo')->cost->toEqual(20); }, + function ($book) { $book->title->toEqual('Bar')->cost->toEqual(30); }, + ); +}); + +class HasMethods +{ + public function name() + { + return 'Has Methods'; + } + + public function quantity() + { + return 20; + } + + public function multiply($x, $y) + { + return $x * $y; + } + + public function attributes() + { + return [ + 'name' => $this->name(), + 'quantity' => $this->quantity() + ]; + } + + public function books() + { + return [ + [ + 'title' => 'Foo', + 'cost' => 20, + ], + [ + 'title' => 'Bar', + 'cost' => 30, + ], + ]; + } +} diff --git a/tests/Expect/HigherOrder/methodsAndProperties.php b/tests/Expect/HigherOrder/methodsAndProperties.php new file mode 100644 index 0000000..fcd25ab --- /dev/null +++ b/tests/Expect/HigherOrder/methodsAndProperties.php @@ -0,0 +1,49 @@ +name->toEqual('Has Methods and Properties')->not()->toEqual('bar') + ->multiply(3, 4)->not->toBeString->toEqual(12) + ->posts->each(fn ($post) => $post->is_published->toBeTrue) + ->books()->toBeArray() + ->posts->toBeArray->each->not->toBeEmpty + ->books()->sequence( + function ($book) { $book->title->toEqual('Foo')->cost->toEqual(20); }, + function ($book) { $book->title->toEqual('Bar')->cost->toEqual(30); }, + ); +}); + +class HasMethodsAndProperties +{ + public $name = 'Has Methods and Properties'; + + public $posts = [ + [ + 'is_published' => true, + 'title' => 'Foo' + ], + [ + 'is_published' => true, + 'title' => 'Bar' + ] + ]; + + public function books() + { + return [ + [ + 'title' => 'Foo', + 'cost' => 20, + ], + [ + 'title' => 'Bar', + 'cost' => 30, + ], + ]; + } + + public function multiply($x, $y) + { + return $x * $y; + } +} diff --git a/tests/Expect/HigherOrder/properties.php b/tests/Expect/HigherOrder/properties.php new file mode 100644 index 0000000..2a61a2d --- /dev/null +++ b/tests/Expect/HigherOrder/properties.php @@ -0,0 +1,75 @@ + 1])->foo->toBeInt()->toEqual(1); +}); + +it('can access multiple properties from the value', function () { + expect(['foo' => 'bar', 'hello' => 'world']) + ->foo->toBeString()->toEqual('bar') + ->hello->toBeString()->toEqual('world'); +}); + +it('works with not', function () { + expect(['foo' => 'bar', 'hello' => 'world']) + ->foo->not->not->toEqual('bar') + ->foo->not->toEqual('world')->toEqual('bar') + ->hello->toEqual('world')->not()->toEqual('bar')->not->toBeNull; +}); + +it('works with each', function () { + expect(['numbers' => [1,2,3,4], 'words' => ['hey', 'there']]) + ->numbers->toEqual([1,2,3,4])->each->toBeInt->toBeLessThan(5) + ->words->each(function ($word) { + $word->toBeString()->not->toBeInt(); + }); +}); + +it('works inside of each', function () { + expect(['books' => [['title' => 'Foo', 'cost' => 20], ['title' => 'Bar', 'cost' => 30]]]) + ->books->each(function ($book) { + $book->title->not->toBeNull->cost->toBeGreaterThan(19); + }); +}); + +it('works with sequence', function () { + expect(['books' => [['title' => 'Foo', 'cost' => 20], ['title' => 'Bar', 'cost' => 30]]]) + ->books->sequence( + function ($book) { $book->title->toEqual('Foo')->cost->toEqual(20); }, + function ($book) { $book->title->toEqual('Bar')->cost->toEqual(30); }, + ); +}); + +it('can compose complex expectations', function () { + expect(['foo' => 'bar', 'numbers' => [1,2,3,4]]) + ->toContain('bar')->toBeArray() + ->numbers->toEqual([1,2,3,4])->not()->toEqual('bar')->each->toBeInt + ->foo->not->toEqual('world')->toEqual('bar') + ->numbers->toBeArray(); +}); + +it('works with objects', function () { + expect(new HasProperties) + ->name->toEqual('foo')->not->toEqual('world') + ->posts->toHaveCount(2)->each(function ($post) { $post->is_published->toBeTrue(); }) + ->posts->sequence( + function ($post) { $post->title->toEqual('Foo'); }, + function ($post) { $post->title->toEqual('Bar'); }, + ); +}); + +class HasProperties +{ + public $name = 'foo'; + + public $posts = [ + [ + 'is_published' => true, + 'title' => 'Foo' + ], + [ + 'is_published' => true, + 'title' => 'Bar' + ] + ]; +}