implemements pipelines and adds tests for it

This commit is contained in:
Fabio Ivona
2021-10-31 15:23:28 +01:00
parent 8174f2d973
commit 3943919709
7 changed files with 421 additions and 17 deletions

View File

@ -6,6 +6,8 @@ namespace Pest\Concerns;
use BadMethodCallException; use BadMethodCallException;
use Closure; use Closure;
use Pest\Expectation;
use PHPStan\Type\CallableType;
/** /**
* @internal * @internal
@ -19,6 +21,9 @@ trait Extendable
*/ */
private static array $extends = []; private static array $extends = [];
/** @var array<string, array<Closure>> */
private static array $pipes = [];
/** /**
* Register a new extend. * Register a new extend.
*/ */
@ -35,6 +40,71 @@ trait Extendable
return array_key_exists($name, static::$extends); 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. * Dynamically handle calls to the class.
* *

View File

@ -170,7 +170,7 @@ final class CoreExpectation
*/ */
public function toStartWith(string $expected): CoreExpectation public function toStartWith(string $expected): CoreExpectation
{ {
Assert::assertStringStartsWith($expected, $this->value); Assert::assertStringStartsWith($expected, $this->value); //@phpstan-ignore-line
return $this; return $this;
} }
@ -180,7 +180,7 @@ final class CoreExpectation
*/ */
public function toEndWith(string $expected): CoreExpectation public function toEndWith(string $expected): CoreExpectation
{ {
Assert::assertStringEndsWith($expected, $this->value); Assert::assertStringEndsWith($expected, $this->value); //@phpstan-ignore-line
return $this; return $this;
} }
@ -322,7 +322,6 @@ final class CoreExpectation
*/ */
public function toBeInstanceOf(string $class): CoreExpectation public function toBeInstanceOf(string $class): CoreExpectation
{ {
/* @phpstan-ignore-next-line */
Assert::assertInstanceOf($class, $this->value); Assert::assertInstanceOf($class, $this->value);
return $this; return $this;

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Pest\Exceptions; namespace Pest\Exceptions;
use InvalidArgumentException; use InvalidArgumentException;
@ -11,7 +13,6 @@ final class ExpectationNotFoundException extends InvalidArgumentException
{ {
public function __construct(string $expectationName) public function __construct(string $expectationName)
{ {
parent::__construct(sprintf("Impossible to find [%s] expectation", $expectationName)); parent::__construct(sprintf('Impossible to find [%s] expectation', $expectationName));
} }
} }

View File

@ -9,9 +9,9 @@ use Closure;
use Pest\Concerns\Extendable; use Pest\Concerns\Extendable;
use Pest\Concerns\RetrievesValues; use Pest\Concerns\RetrievesValues;
use Pest\Exceptions\ExpectationNotFoundException; use Pest\Exceptions\ExpectationNotFoundException;
use Pest\Support\ExpectationPipeline;
use PHPUnit\Framework\Assert; use PHPUnit\Framework\Assert;
use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\ExpectationFailedException;
use SebastianBergmann\Exporter\Exporter;
/** /**
* @internal * @internal
@ -19,7 +19,7 @@ use SebastianBergmann\Exporter\Exporter;
* @template TValue * @template TValue
* *
* @property Expectation $not Creates the opposite expectation. * @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 * @mixin CoreExpectation
*/ */
@ -81,8 +81,6 @@ final class Expectation
/** /**
* Send the expectation value to Ray along with all given arguments. * Send the expectation value to Ray along with all given arguments.
*
* @param ...mixed $arguments
*/ */
public function ray(mixed ...$arguments): self public function ray(mixed ...$arguments): self
{ {
@ -133,16 +131,16 @@ final class Expectation
throw new BadMethodCallException('Expectation value is not iterable.'); throw new BadMethodCallException('Expectation value is not iterable.');
} }
$value = is_array($this->value) ? $this->value : iterator_to_array($this->value); $value = is_array($this->value) ? $this->value : iterator_to_array($this->value);
$keys = array_keys($value); $keys = array_keys($value);
$values = array_values($value); $values = array_values($value);
$callbacksCount = count($callbacks); $callbacksCount = count($callbacks);
$index = 0; $index = 0;
while (count($callbacks) < count($values)) { while (count($callbacks) < count($values)) {
$callbacks[] = $callbacks[$index]; $callbacks[] = $callbacks[$index];
$index = $index < count($values) - 1 ? $index + 1 : 0; $index = $index < count($values) - 1 ? $index + 1 : 0;
} }
if ($callbacksCount > count($values)) { if ($callbacksCount > count($values)) {
@ -214,7 +212,7 @@ final class Expectation
$condition = is_callable($condition) $condition = is_callable($condition)
? $condition ? $condition
: static function () use ($condition): bool { : static function () use ($condition): bool {
return $condition; // @phpstan-ignore-line return $condition;
}; };
return $this->when(!$condition(), $callback); return $this->when(!$condition(), $callback);
@ -231,7 +229,7 @@ final class Expectation
$condition = is_callable($condition) $condition = is_callable($condition)
? $condition ? $condition
: static function () use ($condition): bool { : static function () use ($condition): bool {
return $condition; // @phpstan-ignore-line return $condition;
}; };
if ($condition()) { if ($condition()) {
@ -256,7 +254,10 @@ final class Expectation
return new HigherOrderExpectation($this, $this->value->$method(...$parameters)); 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; return $this;
} }
@ -295,7 +296,7 @@ final class Expectation
private function getExpectationClosure(string $name): Closure private function getExpectationClosure(string $name): Closure
{ {
if (method_exists($this->coreExpectation, $name)) { if (method_exists($this->coreExpectation, $name)) {
/** @phpstan-ignore-next-line */ /* @phpstan-ignore-next-line */
return Closure::fromCallable([$this->coreExpectation, $name]); return Closure::fromCallable([$this->coreExpectation, $name]);
} }

View 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);
}
}

View File

@ -24,4 +24,17 @@ final class Extendable
{ {
$this->extendableClass::extend($name, $extend); $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);
}
} }

View 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);
});