diff --git a/src/Concerns/Extendable.php b/src/Concerns/Extendable.php index 5cf3e79d..792be193 100644 --- a/src/Concerns/Extendable.php +++ b/src/Concerns/Extendable.php @@ -6,6 +6,8 @@ namespace Pest\Concerns; use BadMethodCallException; use Closure; +use Pest\Expectation; +use PHPStan\Type\CallableType; /** * @internal @@ -19,6 +21,9 @@ trait Extendable */ private static array $extends = []; + /** @var array> */ + private static array $pipes = []; + /** * Register a new extend. */ @@ -35,6 +40,71 @@ trait Extendable return array_key_exists($name, static::$extends); } + /** + * Register a pipe to be applied before an expectation is checked. + */ + public static function pipe(string $name, Closure $handler): void + { + self::$pipes[$name][] = $handler; + } + + /** + * Register an interceptor that should replace an existing expectation. + */ + public static function intercept(string $name, string|Closure $filter, Closure $handler): void + { + if (is_string($filter)) { + $filter = fn ($value, ...$arguments): bool => $value instanceof $filter; + } + + self::pipe($name, function ($next, ...$arguments) use ($handler, $filter): void { + /** @phpstan-ignore-next-line */ + if ($filter($this->value, ...$arguments)) { + /** @phpstan-ignore-next-line */ + $handler = $handler->bindTo($this, get_class($this)); + + if($handler instanceof Closure){ + $handler(...$arguments); + } + + return; + } + + $next(); + }); + } + + /** + * Checks if pipes are registered for a given expectation. + */ + public static function hasPipes(string $name): bool + { + return array_key_exists($name, static::$pipes); + } + + /** + * Gets the pipes that have been registered for a given expectation and binds them to a context and a scope. + * + * @return array + */ + private function pipes(string $name, object $context, string $scope): array + { + if (!self::hasPipes($name)) { + return []; + } + + $pipes = []; + foreach (self::$pipes[$name] as $pipe) { + $pipe = $pipe->bindTo($context, $scope); + + if($pipe instanceof Closure){ + $pipes[] = $pipe; + } + } + + return $pipes; + } + /** * Dynamically handle calls to the class. * diff --git a/src/CoreExpectation.php b/src/CoreExpectation.php index dac9d6b4..83c36348 100644 --- a/src/CoreExpectation.php +++ b/src/CoreExpectation.php @@ -170,7 +170,7 @@ final class CoreExpectation */ public function toStartWith(string $expected): CoreExpectation { - Assert::assertStringStartsWith($expected, $this->value); + Assert::assertStringStartsWith($expected, $this->value); //@phpstan-ignore-line return $this; } @@ -180,7 +180,7 @@ final class CoreExpectation */ public function toEndWith(string $expected): CoreExpectation { - Assert::assertStringEndsWith($expected, $this->value); + Assert::assertStringEndsWith($expected, $this->value); //@phpstan-ignore-line return $this; } @@ -322,7 +322,6 @@ final class CoreExpectation */ public function toBeInstanceOf(string $class): CoreExpectation { - /* @phpstan-ignore-next-line */ Assert::assertInstanceOf($class, $this->value); return $this; diff --git a/src/Exceptions/ExpectationNotFoundException.php b/src/Exceptions/ExpectationNotFoundException.php index 5b9c61ec..07bafdc3 100644 --- a/src/Exceptions/ExpectationNotFoundException.php +++ b/src/Exceptions/ExpectationNotFoundException.php @@ -1,5 +1,7 @@ value) ? $this->value : iterator_to_array($this->value); - $keys = array_keys($value); - $values = array_values($value); + $value = is_array($this->value) ? $this->value : iterator_to_array($this->value); + $keys = array_keys($value); + $values = array_values($value); $callbacksCount = count($callbacks); $index = 0; while (count($callbacks) < count($values)) { $callbacks[] = $callbacks[$index]; - $index = $index < count($values) - 1 ? $index + 1 : 0; + $index = $index < count($values) - 1 ? $index + 1 : 0; } if ($callbacksCount > count($values)) { @@ -214,7 +212,7 @@ final class Expectation $condition = is_callable($condition) ? $condition : static function () use ($condition): bool { - return $condition; // @phpstan-ignore-line + return $condition; }; return $this->when(!$condition(), $callback); @@ -231,7 +229,7 @@ final class Expectation $condition = is_callable($condition) ? $condition : static function () use ($condition): bool { - return $condition; // @phpstan-ignore-line + return $condition; }; if ($condition()) { @@ -256,7 +254,10 @@ final class Expectation return new HigherOrderExpectation($this, $this->value->$method(...$parameters)); } - $this->getExpectationClosure($method)(...$parameters); + ExpectationPipeline::for($this->getExpectationClosure($method)) + ->send(...$parameters) + ->through($this->pipes($method, $this, Expectation::class)) + ->run(); return $this; } @@ -295,7 +296,7 @@ final class Expectation private function getExpectationClosure(string $name): Closure { if (method_exists($this->coreExpectation, $name)) { - /** @phpstan-ignore-next-line */ + /* @phpstan-ignore-next-line */ return Closure::fromCallable([$this->coreExpectation, $name]); } diff --git a/src/Support/ExpectationPipeline.php b/src/Support/ExpectationPipeline.php new file mode 100644 index 00000000..557c82e5 --- /dev/null +++ b/src/Support/ExpectationPipeline.php @@ -0,0 +1,65 @@ + */ + private array $pipes = []; + + /** @var array */ + private array $passable; + + public function __construct( + private Closure $expectationClosure + ) { + //.. + } + + public static function for(Closure $expectationClosure): ExpectationPipeline + { + return new self($expectationClosure); + } + + public function send(...$passable): ExpectationPipeline + { + $this->passable = $passable; + + return $this; + } + + /** + * @param array $pipes + */ + public function through(array $pipes): ExpectationPipeline + { + $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 fn ($stack, $pipe): Closure => fn () => $pipe($stack, ...$this->passable); + } +} diff --git a/src/Support/Extendable.php b/src/Support/Extendable.php index 094d7007..17150050 100644 --- a/src/Support/Extendable.php +++ b/src/Support/Extendable.php @@ -24,4 +24,17 @@ final class Extendable { $this->extendableClass::extend($name, $extend); } + + /** + * Register pipe to be applied to the given expectation. + */ + public function pipe(string $name, Closure $handler): void + { + $this->extendableClass::pipe($name, $handler); + } + + public function intercept(string $name, string|Closure $filter, Closure $handler): void + { + $this->extendableClass::intercept($name, $filter, $handler); + } } diff --git a/tests/Features/Expect/pipes.php b/tests/Features/Expect/pipes.php new file mode 100644 index 00000000..c98d25fe --- /dev/null +++ b/tests/Features/Expect/pipes.php @@ -0,0 +1,255 @@ +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 lets the pipeline to 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); +});