mirror of
https://github.com/pestphp/pest.git
synced 2026-03-06 07:47:22 +01:00
implemements pipelines and adds tests for it
This commit is contained in:
@ -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<string, array<Closure>> */
|
||||
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<int, Closure>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
@ -11,7 +13,6 @@ final class ExpectationNotFoundException extends InvalidArgumentException
|
||||
{
|
||||
public function __construct(string $expectationName)
|
||||
{
|
||||
parent::__construct(sprintf("Impossible to find [%s] expectation", $expectationName));
|
||||
parent::__construct(sprintf('Impossible to find [%s] expectation', $expectationName));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -9,9 +9,9 @@ use Closure;
|
||||
use Pest\Concerns\Extendable;
|
||||
use Pest\Concerns\RetrievesValues;
|
||||
use Pest\Exceptions\ExpectationNotFoundException;
|
||||
use Pest\Support\ExpectationPipeline;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
use SebastianBergmann\Exporter\Exporter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -19,7 +19,7 @@ use SebastianBergmann\Exporter\Exporter;
|
||||
* @template TValue
|
||||
*
|
||||
* @property Expectation $not Creates the opposite expectation.
|
||||
* @property Each $each Creates an expectation on each element on the traversable value.
|
||||
* @property Each $each Creates an expectation on each element on the traversable value.
|
||||
*
|
||||
* @mixin CoreExpectation
|
||||
*/
|
||||
@ -81,8 +81,6 @@ final class Expectation
|
||||
|
||||
/**
|
||||
* Send the expectation value to Ray along with all given arguments.
|
||||
*
|
||||
* @param ...mixed $arguments
|
||||
*/
|
||||
public function ray(mixed ...$arguments): self
|
||||
{
|
||||
@ -133,16 +131,16 @@ final class Expectation
|
||||
throw new BadMethodCallException('Expectation value is not iterable.');
|
||||
}
|
||||
|
||||
$value = is_array($this->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]);
|
||||
}
|
||||
|
||||
|
||||
65
src/Support/ExpectationPipeline.php
Normal file
65
src/Support/ExpectationPipeline.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Support;
|
||||
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ExpectationPipeline
|
||||
{
|
||||
/** @var array<Closure> */
|
||||
private array $pipes = [];
|
||||
|
||||
/** @var array<int|string, mixed> */
|
||||
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<Closure> $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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
255
tests/Features/Expect/pipes.php
Normal file
255
tests/Features/Expect/pipes.php
Normal file
@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertEquals;
|
||||
use function PHPUnit\Framework\assertEqualsIgnoringCase;
|
||||
use function PHPUnit\Framework\assertInstanceOf;
|
||||
use function PHPUnit\Framework\assertSame;
|
||||
|
||||
class Number
|
||||
{
|
||||
public function __construct(
|
||||
public int $value
|
||||
) {
|
||||
//..
|
||||
}
|
||||
}
|
||||
|
||||
class Char
|
||||
{
|
||||
public function __construct(
|
||||
public string $value
|
||||
) {
|
||||
//..
|
||||
}
|
||||
}
|
||||
|
||||
class Symbol
|
||||
{
|
||||
public function __construct(
|
||||
public string $value
|
||||
) {
|
||||
//..
|
||||
}
|
||||
}
|
||||
|
||||
class State
|
||||
{
|
||||
public $runCount = [];
|
||||
public $appliedCount = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
});
|
||||
Reference in New Issue
Block a user